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,
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");
68
69use sloc_core::{
70 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
71 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
72};
73use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html, write_pdf_from_run};
74const MAX_CONCURRENT_ANALYSES: usize = 4;
75
76#[cfg(all(target_os = "windows", feature = "native-dialog"))]
84#[allow(clippy::upper_case_acronyms)]
85mod win_dialog_focus {
86 use std::mem::size_of;
87
88 type HWND = *mut core::ffi::c_void;
89 type DWORD = u32;
90 type UINT = u32;
91 type BOOL = i32;
92
93 #[repr(C)]
97 #[allow(non_snake_case)]
98 struct FLASHWINFO {
99 cbSize: UINT,
100 hwnd: HWND,
101 dwFlags: DWORD,
102 uCount: UINT,
103 dwTimeout: DWORD,
104 }
105
106 const FLASHW_ALL: DWORD = 0x3;
107 const FLASHW_TIMERNOFG: DWORD = 0xC;
108
109 #[link(name = "user32")]
110 extern "system" {
111 fn GetForegroundWindow() -> HWND;
112 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
113 fn BringWindowToTop(hWnd: HWND) -> BOOL;
114 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
115 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
116 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
117 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
118 }
119
120 #[link(name = "kernel32")]
121 extern "system" {
122 fn GetCurrentThreadId() -> DWORD;
123 }
124
125 pub fn attach_to_foreground() -> DWORD {
130 unsafe {
131 let fg_hwnd = GetForegroundWindow();
132 if fg_hwnd.is_null() {
133 return 0;
134 }
135 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
136 let my_tid = GetCurrentThreadId();
137 if fg_tid == my_tid {
138 return 0;
139 }
140 AttachThreadInput(my_tid, fg_tid, 1);
141 fg_tid
142 }
143 }
144
145 pub fn detach_from_foreground(fg_tid: DWORD) {
147 if fg_tid == 0 {
148 return;
149 }
150 unsafe {
151 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
152 }
153 }
154
155 pub fn flash_dialog_when_ready(title: String) {
159 std::thread::spawn(move || {
160 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
161 for _ in 0..40 {
162 std::thread::sleep(std::time::Duration::from_millis(80));
163 unsafe {
164 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
165 if !hwnd.is_null() {
166 SetForegroundWindow(hwnd);
167 BringWindowToTop(hwnd);
168 #[allow(non_snake_case)]
169 FlashWindowEx(&FLASHWINFO {
170 #[allow(clippy::cast_possible_truncation)]
173 cbSize: size_of::<FLASHWINFO>() as UINT,
174 hwnd,
175 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
176 uCount: 3,
177 dwTimeout: 0,
178 });
179 break;
180 }
181 }
182 }
183 });
184 }
185}
186
187pub(crate) struct IpRateLimiter {
190 window: Duration,
191 max_requests: usize,
192 pub(crate) auth_lockout_threshold: u32,
193 auth_lockout_window: Duration,
194 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
195 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
196}
197
198impl IpRateLimiter {
199 pub(crate) fn new(
200 window: Duration,
201 max_requests: usize,
202 auth_lockout_threshold: u32,
203 auth_lockout_window: Duration,
204 ) -> Self {
205 Self {
206 window,
207 max_requests,
208 auth_lockout_threshold,
209 auth_lockout_window,
210 state: std::sync::Mutex::new(HashMap::new()),
211 auth_failures: std::sync::Mutex::new(HashMap::new()),
212 }
213 }
214
215 #[allow(clippy::significant_drop_tightening)]
218 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
219 let now = Instant::now();
220 let cutoff = now.checked_sub(self.window).unwrap_or(now);
221 let mut state = self
222 .state
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner);
225 if state.len() > 10_000 {
226 state.retain(|_, bucket| {
227 while bucket.front().is_some_and(|t| *t <= cutoff) {
228 bucket.pop_front();
229 }
230 !bucket.is_empty()
231 });
232 }
233 let bucket = state.entry(ip).or_default();
234 while bucket.front().is_some_and(|t| *t <= cutoff) {
235 bucket.pop_front();
236 }
237 if bucket.len() >= self.max_requests {
238 false
239 } else {
240 bucket.push_back(now);
241 true
242 }
243 }
244
245 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
246 let now = Instant::now();
247 let mut map = self
248 .auth_failures
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner);
251 map.entry(ip)
252 .and_modify(|e| {
253 e.0 += 1;
254 e.1 = now;
255 })
256 .or_insert_with(|| (1, now));
257 }
258
259 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
260 let mut map = self
261 .auth_failures
262 .lock()
263 .unwrap_or_else(std::sync::PoisonError::into_inner);
264 let expired = map
265 .get(&ip)
266 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
267 if expired {
268 map.remove(&ip);
269 return false;
270 }
271 map.get(&ip)
272 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
273 }
274
275 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
276 let map = self
277 .auth_failures
278 .lock()
279 .unwrap_or_else(std::sync::PoisonError::into_inner);
280 map.get(&ip).map_or(0, |e| {
281 self.auth_lockout_window
282 .checked_sub(e.1.elapsed())
283 .map_or(0, |r| r.as_secs())
284 })
285 }
286
287 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
288 tokio::spawn(async move {
289 let mut interval = tokio::time::interval(Duration::from_mins(1));
290 interval.tick().await; loop {
292 interval.tick().await;
293 let now = Instant::now();
294 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
295 {
296 let mut state = limiter
297 .state
298 .lock()
299 .unwrap_or_else(std::sync::PoisonError::into_inner);
300 state.retain(|_, bucket| {
301 while bucket.front().is_some_and(|t| *t <= cutoff) {
302 bucket.pop_front();
303 }
304 !bucket.is_empty()
305 });
306 }
307 {
308 let mut auth = limiter
309 .auth_failures
310 .lock()
311 .unwrap_or_else(std::sync::PoisonError::into_inner);
312 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
313 }
314 }
315 });
316 }
317}
318
319fn spawn_upload_staging_cleanup() {
323 tokio::spawn(async move {
324 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
325 .ok()
326 .and_then(|v| v.parse().ok())
327 .unwrap_or(4);
328 let ttl_secs = ttl_hours * 3600;
329 let mut interval = tokio::time::interval(Duration::from_hours(1));
330 interval.tick().await; loop {
332 interval.tick().await;
333 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
334 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
335 continue;
336 };
337 while let Ok(Some(entry)) = dir.next_entry().await {
338 let path = entry.path();
339 let age_secs = tokio::fs::metadata(&path)
340 .await
341 .ok()
342 .and_then(|m| m.modified().ok())
343 .and_then(|t| t.elapsed().ok())
344 .map_or(0, |d| d.as_secs());
345 if age_secs > ttl_secs {
346 tracing::debug!(
347 event = "upload_staging_cleanup",
348 path = %path.display(),
349 age_secs,
350 "removing stale upload staging directory"
351 );
352 let _ = tokio::fs::remove_dir_all(&path).await;
353 }
354 }
355 }
356 });
357}
358
359#[derive(Clone, Debug, Default)]
361struct RunResultContext {
362 prev_entry: Option<RegistryEntry>,
363 prev_scan_count: usize,
364 project_path: String,
365}
366
367#[derive(Clone)]
369enum AsyncRunState {
370 Running {
371 started_at: std::time::Instant,
372 cancel_token: Arc<std::sync::atomic::AtomicBool>,
373 },
374 Complete {
376 run_id: String,
377 },
378 Failed {
379 message: String,
380 },
381 Cancelled,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
387struct ScanProfile {
388 id: String,
389 name: String,
390 created_at: String,
391 params: serde_json::Value,
393}
394
395#[derive(Debug, Clone, Default, Serialize, Deserialize)]
396struct ScanProfileStore {
397 profiles: Vec<ScanProfile>,
398}
399
400impl ScanProfileStore {
401 fn load(path: &std::path::Path) -> Self {
402 fs::read_to_string(path)
403 .ok()
404 .and_then(|s| serde_json::from_str(&s).ok())
405 .unwrap_or_default()
406 }
407
408 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
409 if let Some(parent) = path.parent() {
410 fs::create_dir_all(parent)?;
411 }
412 let json = serde_json::to_string_pretty(self)?;
413 fs::write(path, json)?;
414 Ok(())
415 }
416}
417
418#[derive(Clone)]
419pub(crate) struct AppState {
420 pub(crate) base_config: AppConfig,
421 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
422 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
423 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
424 pub(crate) registry_path: PathBuf,
425 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
426 pub(crate) server_mode: bool,
427 pub(crate) tls_enabled: bool,
428 pub(crate) api_keys: Vec<secrecy::Secret<String>>,
429 pub(crate) rate_limiter: Arc<IpRateLimiter>,
430 pub(crate) trust_proxy: bool,
431 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
434 pub(crate) git_clones_dir: PathBuf,
436 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
438 pub(crate) schedules_path: PathBuf,
439 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
441 pub(crate) scan_profiles_path: PathBuf,
442 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
443 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
445 pub(crate) confluence_path: PathBuf,
446 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
448 pub(crate) watched_dirs_path: PathBuf,
449}
450
451type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
452
453#[derive(Clone, Debug)]
456pub(crate) struct RunArtifacts {
457 output_dir: PathBuf,
458 html_path: Option<PathBuf>,
459 pdf_path: Option<PathBuf>,
460 json_path: Option<PathBuf>,
461 csv_path: Option<PathBuf>,
462 xlsx_path: Option<PathBuf>,
463 scan_config_path: Option<PathBuf>,
464 report_title: String,
465 result_context: RunResultContext,
466}
467
468#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
470 let protected = Router::new()
471 .route("/", get(splash))
472 .route("/scan-setup", get(scan_setup_handler))
473 .route("/scan", get(index))
474 .route("/analyze", post(analyze_handler))
475 .route("/preview", get(preview_handler))
476 .route("/api/suggest-coverage", get(api_suggest_coverage))
477 .route("/pick-directory", get(pick_directory_handler))
478 .route("/open-path", get(open_path_handler))
479 .route("/pick-file", get(pick_file_handler))
480 .route(
481 "/api/upload-directory",
482 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
483 )
484 .route(
485 "/api/upload-file",
486 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
487 )
488 .route(
489 "/api/upload-tarball",
490 post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
491 )
492 .route("/locate-report", post(locate_report_handler))
493 .route("/locate-reports-dir", post(locate_reports_dir_handler))
494 .route("/relocate-scan", post(relocate_scan_handler))
495 .route("/watched-dirs/add", post(add_watched_dir_handler))
496 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
497 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
498 .route("/view-reports", get(history_handler))
499 .route("/compare-scans", get(compare_select_handler))
500 .route("/compare", get(compare_handler))
501 .route("/images/{folder}/{file}", get(image_handler))
502 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
503 .route("/api/metrics/latest", get(api_metrics_latest_handler))
504 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
505 .route("/api/metrics/history", get(api_metrics_history_handler))
506 .route(
507 "/api/metrics/submodules",
508 get(api_metrics_submodules_handler),
509 )
510 .route("/api/ingest", post(api_ingest_handler))
511 .route("/api/project-history", get(project_history_handler))
512 .route("/trend-reports", get(trend_report_handler))
513 .route("/test-metrics", get(test_metrics_handler))
514 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
515 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
516 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
517 .route("/runs/result/{run_id}", get(async_run_result_handler))
518 .route("/embed/summary", get(embed_handler))
519 .route("/git-browser", get(git_browser::git_browser_handler))
521 .route("/api/git/refs", get(git_browser::api_list_refs))
522 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
523 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
524 .route("/export-config", get(export_config_handler))
526 .route("/import-config", post(import_config_handler))
527 .route("/api/scan-profiles", get(api_list_scan_profiles))
529 .route("/api/scan-profiles", post(api_save_scan_profile))
530 .route(
531 "/api/scan-profiles/{id}",
532 axum::routing::delete(api_delete_scan_profile),
533 )
534 .route("/integrations", get(integrations::integrations_handler))
536 .route(
537 "/webhook-setup",
538 get(|| async { axum::response::Redirect::permanent("/integrations") }),
539 )
540 .route(
541 "/confluence-setup",
542 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
543 )
544 .route("/api/schedules", get(git_webhook::api_list_schedules))
545 .route("/api/schedules", post(git_webhook::api_create_schedule))
546 .route(
547 "/api/schedules",
548 axum::routing::delete(git_webhook::api_delete_schedule),
549 )
550 .route(
551 "/api/confluence/config",
552 get(confluence::api_get_confluence_config),
553 )
554 .route(
555 "/api/confluence/config",
556 post(confluence::api_save_confluence_config),
557 )
558 .route(
559 "/api/confluence/test",
560 post(confluence::api_test_confluence),
561 )
562 .route(
563 "/api/confluence/post",
564 post(confluence::api_post_to_confluence),
565 )
566 .route(
567 "/api/confluence/wiki-markup",
568 get(confluence::api_wiki_markup),
569 )
570 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
572 .route(
573 "/api/runs/{run_id}",
574 axum::routing::delete(delete_run_handler),
575 )
576 .route("/api/runs/cleanup", post(cleanup_runs_handler))
577 .route("/api-docs", get(api_docs_handler))
579 .route_layer(middleware::from_fn_with_state(
580 state.clone(),
581 auth::require_api_key,
582 ));
583
584 protected
585 .route("/healthz", get(healthz))
586 .route("/api/health", get(healthz))
587 .route("/api/version", get(api_version_handler))
588 .route("/api/openapi.yaml", get(openapi_yaml_handler))
589 .route("/badge/{metric}", get(badge_handler))
590 .route("/static/chart.js", get(chart_js_handler))
591 .route("/auth/login", get(auth::auth_login_get))
592 .route("/auth/login", post(auth::auth_login_post))
593 .route(
596 "/webhooks/github",
597 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
598 )
599 .route(
600 "/webhooks/gitlab",
601 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
602 )
603 .route(
604 "/webhooks/bitbucket",
605 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
606 )
607 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
608 .layer(middleware::from_fn_with_state(
609 state.clone(),
610 add_security_headers,
611 ))
612 .layer(build_cors_layer(state.server_mode))
613 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
614 .with_state(state)
615}
616
617pub fn make_test_router() -> Router {
619 let tmp = std::env::temp_dir().join("sloc_test");
620 let state = AppState {
621 base_config: AppConfig::default(),
622 artifacts: Arc::new(Mutex::new(HashMap::new())),
623 async_runs: Arc::new(Mutex::new(HashMap::new())),
624 registry: Arc::new(Mutex::new(ScanRegistry::default())),
625 registry_path: tmp.join("registry.json"),
626 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
627 server_mode: false,
628 tls_enabled: false,
629 api_keys: vec![],
630 rate_limiter: Arc::new(IpRateLimiter::new(
631 Duration::from_mins(1),
632 600,
633 10,
634 Duration::from_hours(1),
635 )),
636 trust_proxy: false,
637 trusted_proxy_ips: vec![],
638 git_clones_dir: tmp.join("git-clones"),
639 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
640 schedules_path: tmp.join("schedules.json"),
641 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
642 scan_profiles_path: tmp.join("scan_profiles.json"),
643 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
644 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
645 confluence_path: tmp.join("confluence_config.json"),
646 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
647 watched_dirs_path: tmp.join("watched_dirs.json"),
648 };
649 build_router(state)
650}
651
652pub fn make_test_router_with_key(api_key: &str) -> Router {
654 let tmp = std::env::temp_dir().join("sloc_test_key");
655 let state = AppState {
656 base_config: AppConfig::default(),
657 artifacts: Arc::new(Mutex::new(HashMap::new())),
658 async_runs: Arc::new(Mutex::new(HashMap::new())),
659 registry: Arc::new(Mutex::new(ScanRegistry::default())),
660 registry_path: tmp.join("registry.json"),
661 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
662 server_mode: false,
663 tls_enabled: false,
664 api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
665 rate_limiter: Arc::new(IpRateLimiter::new(
666 Duration::from_mins(1),
667 600,
668 10,
669 Duration::from_hours(1),
670 )),
671 trust_proxy: false,
672 trusted_proxy_ips: vec![],
673 git_clones_dir: tmp.join("git-clones"),
674 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
675 schedules_path: tmp.join("schedules.json"),
676 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
677 scan_profiles_path: tmp.join("scan_profiles.json"),
678 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
679 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
680 confluence_path: tmp.join("confluence_config.json"),
681 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
682 watched_dirs_path: tmp.join("watched_dirs.json"),
683 };
684 build_router(state)
685}
686
687struct RuntimeSecurityConfig {
688 api_keys: Vec<secrecy::Secret<String>>,
689 tls_cert: Option<String>,
690 tls_key: Option<String>,
691 tls_enabled: bool,
692 trust_proxy: bool,
693 trusted_proxy_ips: Vec<IpAddr>,
694 rate_limiter: Arc<IpRateLimiter>,
695}
696
697fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
698 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
699 .or_else(|_| std::env::var("SLOC_API_KEY"))
700 .unwrap_or_default()
701 .split(',')
702 .map(str::trim)
703 .filter(|s| !s.is_empty())
704 .map(|s| secrecy::Secret::new(s.to_owned()))
705 .collect();
706 if server_mode && api_keys.is_empty() {
707 println!(
708 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
709 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
710 );
711 }
712 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
713 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
714 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
715 if server_mode && !tls_enabled {
716 println!(
717 "WARNING: TLS is not configured. Traffic is cleartext. \
718 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
719 or terminate TLS at a reverse proxy (nginx, caddy)."
720 );
721 }
722 if server_mode {
723 println!(
724 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
725 to restrict cross-origin access (comma-separated)."
726 );
727 }
728 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
729 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
730 .unwrap_or_default()
731 .split(',')
732 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
733 .collect();
734 if trust_proxy {
735 if trusted_proxy_ips.is_empty() {
736 println!(
737 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
738 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
739 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
740 );
741 } else {
742 println!(
743 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
744 trusted_proxy_ips
745 .iter()
746 .map(std::string::ToString::to_string)
747 .collect::<Vec<_>>()
748 .join(", ")
749 );
750 }
751 } else if server_mode {
752 println!(
753 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
754 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
755 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
756 enable per-client rate limiting via X-Forwarded-For."
757 );
758 }
759 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
760 println!(
761 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
762 DISABLED for all git operations. Remove this variable before production use."
763 );
764 }
765 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
766 .ok()
767 .and_then(|v| v.parse::<u32>().ok())
768 .unwrap_or(10);
769 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
770 .ok()
771 .and_then(|v| v.parse::<u64>().ok())
772 .unwrap_or(3600);
773 let default_rpm: usize = if server_mode { 120 } else { 600 };
777 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
778 .ok()
779 .and_then(|v| v.parse::<usize>().ok())
780 .unwrap_or(default_rpm);
781 let rate_limiter = Arc::new(IpRateLimiter::new(
782 Duration::from_mins(1),
783 rate_limit_rpm,
784 auth_lockout_threshold,
785 Duration::from_secs(auth_lockout_secs),
786 ));
787 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
788 RuntimeSecurityConfig {
789 api_keys,
790 tls_cert,
791 tls_key,
792 tls_enabled,
793 trust_proxy,
794 trusted_proxy_ips,
795 rate_limiter,
796 }
797}
798
799#[allow(clippy::too_many_lines)]
808pub async fn serve(config: AppConfig) -> Result<()> {
809 let bind_address = config.web.bind_address.clone();
810 let server_mode = config.web.server_mode;
811 let output_root = resolve_output_root(None);
812 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
814 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
815 let mut registry = ScanRegistry::load(®istry_path);
816 registry.prune_stale();
817 let _ = registry.save(®istry_path);
818
819 let sec = load_runtime_security_config(server_mode);
820 spawn_upload_staging_cleanup();
821
822 let git_clones_dir = resolve_git_clones_dir(&output_root);
823 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
824 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
825 let schedules = ScheduleStore::load(&schedules_path);
826 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
827 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
828 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
829 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
830 |_| output_root.join("confluence_config.json"),
831 PathBuf::from,
832 );
833 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
834 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
835 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
836 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
837
838 let state = AppState {
839 base_config: config,
840 artifacts: Arc::new(Mutex::new(HashMap::new())),
841 async_runs: Arc::new(Mutex::new(HashMap::new())),
842 registry: Arc::new(Mutex::new(registry)),
843 registry_path,
844 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
845 server_mode,
846 tls_enabled: sec.tls_enabled,
847 api_keys: sec.api_keys,
848 rate_limiter: sec.rate_limiter,
849 trust_proxy: sec.trust_proxy,
850 trusted_proxy_ips: sec.trusted_proxy_ips,
851 git_clones_dir,
852 schedules: Arc::new(Mutex::new(schedules)),
853 schedules_path,
854 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
855 scan_profiles_path,
856 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
857 confluence: Arc::new(Mutex::new(confluence)),
858 confluence_path,
859 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
860 watched_dirs_path,
861 };
862
863 restart_poll_schedules(&state).await;
864
865 let app = build_router(state.clone());
866
867 let preferred: SocketAddr = bind_address
872 .parse()
873 .with_context(|| format!("invalid bind address: {bind_address}"))?;
874 let (listener, addr) = {
875 let candidates = (0u16..=9).map(|offset| {
876 let mut a = preferred;
877 a.set_port(preferred.port().saturating_add(offset));
878 a
879 });
880 let mut found = None;
881 for candidate in candidates {
882 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
883 found = Some((l, candidate));
884 break;
885 }
886 }
887 found.ok_or_else(|| {
888 anyhow::anyhow!(
889 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
890 bind_address,
891 preferred.port(),
892 preferred.port().saturating_add(9)
893 )
894 })?
895 };
896 if addr != preferred {
897 eprintln!(
898 "NOTE: port {} is blocked by a system socket (Windows zombie); \
899 using {} instead.",
900 preferred.port(),
901 addr.port()
902 );
903 }
904
905 if sec.tls_enabled {
906 let cert_path = sec
907 .tls_cert
908 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
909 let key_path = sec
910 .tls_key
911 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
912 let tls_config = build_tls_config(&cert_path, &key_path)
913 .context("failed to load TLS certificate/key")?;
914 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
915
916 let url = format!("https://{addr}/");
917 println!("OxideSLOC server running at {url} (TLS)");
918 println!("Use Ctrl+C to stop.");
919
920 return serve_tls(listener, app, acceptor, server_mode).await;
921 }
922
923 let url = format!("http://{addr}/");
924 log_startup_url(&url, server_mode);
925
926 axum::serve(
927 listener,
928 app.into_make_service_with_connect_info::<SocketAddr>(),
929 )
930 .with_graceful_shutdown(shutdown_signal(server_mode))
931 .await
932 .context("web server terminated unexpectedly")
933}
934
935fn primary_lan_ip() -> Option<String> {
939 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
940 socket.connect("8.8.8.8:80").ok()?;
941 let addr = socket.local_addr().ok()?;
942 let ip = addr.ip();
943 if ip.is_loopback() {
944 return None;
945 }
946 Some(ip.to_string())
947}
948
949fn log_startup_url(url: &str, server_mode: bool) {
951 if server_mode {
952 println!("OxideSLOC server running at {url}");
953 println!("Use Ctrl+C to stop.");
954 } else {
955 println!("OxideSLOC local web UI running at {url}");
956 println!("Press Ctrl+C to stop the server.");
957 let open_url = url.to_owned();
958 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
959 }
960}
961
962fn open_browser_tab(url: &str) {
964 #[cfg(target_os = "windows")]
965 let _ = std::process::Command::new("cmd")
966 .args(["/c", "start", "", url])
967 .stdout(Stdio::null())
968 .stderr(Stdio::null())
969 .spawn();
970 #[cfg(target_os = "macos")]
971 let _ = std::process::Command::new("open")
972 .arg(url)
973 .stdout(Stdio::null())
974 .stderr(Stdio::null())
975 .spawn();
976 #[cfg(target_os = "linux")]
977 let _ = std::process::Command::new("xdg-open")
978 .arg(url)
979 .stdout(Stdio::null())
980 .stderr(Stdio::null())
981 .spawn();
982}
983
984async fn shutdown_signal(server_mode: bool) {
986 if tokio::signal::ctrl_c().await.is_ok() {
987 println!();
988 if server_mode {
989 println!("Shutting down OxideSLOC server...");
990 } else {
991 println!("Shutting down OxideSLOC local web UI...");
992 }
993 println!("Server stopped cleanly.");
994 }
995}
996
997fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
999 use rustls_pemfile::{certs, private_key};
1000 use std::io::BufReader;
1001
1002 let cert_bytes =
1003 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1004 let key_bytes =
1005 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1006
1007 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
1008 .collect::<std::result::Result<_, _>>()
1009 .context("failed to parse TLS certificates")?;
1010
1011 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
1012 .context("failed to parse TLS private key")?
1013 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
1014
1015 rustls::ServerConfig::builder()
1016 .with_no_client_auth()
1017 .with_single_cert(cert_chain, key)
1018 .context("failed to build TLS server config")
1019}
1020
1021async fn serve_tls(
1023 listener: tokio::net::TcpListener,
1024 app: Router,
1025 acceptor: tokio_rustls::TlsAcceptor,
1026 server_mode: bool,
1027) -> Result<()> {
1028 use hyper_util::rt::{TokioExecutor, TokioIo};
1029 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1030 use hyper_util::service::TowerToHyperService;
1031 use tower::{Service, ServiceExt};
1032
1033 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1034
1035 loop {
1036 tokio::select! {
1037 biased;
1038 _ = tokio::signal::ctrl_c() => {
1039 println!();
1040 if server_mode {
1041 println!("Shutting down OxideSLOC server...");
1042 } else {
1043 println!("Shutting down OxideSLOC local web UI...");
1044 }
1045 println!("Server stopped cleanly.");
1046 return Ok(());
1047 }
1048 result = listener.accept() => {
1049 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1050 let acceptor = acceptor.clone();
1051 let mut factory = make_svc.clone();
1052
1053 tokio::spawn(async move {
1054 let tls = match acceptor.accept(tcp).await {
1055 Ok(s) => s,
1056 Err(e) => {
1057 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1058 return;
1059 }
1060 };
1061 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1062 Ok(f) => match Service::call(f, peer_addr).await {
1063 Ok(s) => s,
1064 Err(_) => return,
1065 },
1066 Err(_) => return,
1067 };
1068 let io = TokioIo::new(tls);
1069 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1070 .serve_connection(io, TowerToHyperService::new(svc))
1071 .await
1072 {
1073 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1074 }
1075 });
1076 }
1077 }
1078 }
1079}
1080
1081fn build_cors_layer(server_mode: bool) -> CorsLayer {
1084 if server_mode {
1085 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1086 .unwrap_or_default()
1087 .split(',')
1088 .filter(|s| !s.is_empty())
1089 .filter_map(|s| s.trim().parse().ok())
1090 .collect();
1091 if allowed.is_empty() {
1092 return CorsLayer::new();
1093 }
1094 CorsLayer::new()
1095 .allow_origin(AllowOrigin::list(allowed))
1096 .allow_methods(AllowMethods::list([
1097 axum::http::Method::GET,
1098 axum::http::Method::POST,
1099 ]))
1100 .allow_headers(AllowHeaders::list([
1101 axum::http::header::AUTHORIZATION,
1102 axum::http::header::CONTENT_TYPE,
1103 ]))
1104 } else {
1105 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1106 let s = origin.to_str().unwrap_or("");
1107 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1108 }))
1109 }
1110}
1111
1112async fn add_security_headers(
1113 State(state): State<AppState>,
1114 mut req: Request<Body>,
1115 next: Next,
1116) -> Response {
1117 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1118 req.extensions_mut().insert(CspNonce(nonce.clone()));
1119 let mut resp = next.run(req).await;
1120 let h = resp.headers_mut();
1121 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1122 h.insert(
1123 "X-Content-Type-Options",
1124 HeaderValue::from_static("nosniff"),
1125 );
1126 h.insert(
1127 "Referrer-Policy",
1128 HeaderValue::from_static("strict-origin-when-cross-origin"),
1129 );
1130 let csp = format!(
1131 "default-src 'self'; \
1132 style-src 'self' 'unsafe-inline'; \
1133 img-src 'self' data: blob:; \
1134 script-src 'self' 'nonce-{nonce}'; \
1135 font-src 'self' data:; \
1136 object-src 'none'; \
1137 frame-ancestors 'none'"
1138 );
1139 h.insert(
1140 "Content-Security-Policy",
1141 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1142 HeaderValue::from_static(
1143 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1144 )
1145 }),
1146 );
1147 h.insert(
1148 "X-Permitted-Cross-Domain-Policies",
1149 HeaderValue::from_static("none"),
1150 );
1151 h.insert(
1152 "Permissions-Policy",
1153 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1154 );
1155 h.insert(
1156 "Cross-Origin-Opener-Policy",
1157 HeaderValue::from_static("same-origin"),
1158 );
1159 h.insert(
1160 "Cross-Origin-Resource-Policy",
1161 HeaderValue::from_static("same-origin"),
1162 );
1163 if state.tls_enabled {
1164 h.insert(
1165 "Strict-Transport-Security",
1166 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1167 );
1168 }
1169 resp
1170}
1171
1172async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1173 let peer_ip = req
1174 .extensions()
1175 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1176 .map(|c| c.0.ip());
1177
1178 let ip = peer_ip
1182 .and_then(|peer| {
1183 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1184 req.headers()
1185 .get("X-Forwarded-For")
1186 .and_then(|v| v.to_str().ok())
1187 .and_then(|s| s.split(',').next())
1188 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1189 } else {
1190 None
1191 }
1192 })
1193 .or(peer_ip)
1194 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1195
1196 if !state.rate_limiter.is_allowed(ip) {
1197 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1198 path = %req.uri().path(), "Rate limit exceeded");
1199 return (
1200 StatusCode::TOO_MANY_REQUESTS,
1201 [(header::RETRY_AFTER, "60")],
1202 "429 Too Many Requests\n",
1203 )
1204 .into_response();
1205 }
1206 next.run(req).await
1207}
1208
1209async fn splash(
1210 State(state): State<AppState>,
1211 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1212) -> impl IntoResponse {
1213 let lan_ip = if state.server_mode {
1214 primary_lan_ip()
1215 } else {
1216 None
1217 };
1218 let port = state
1219 .base_config
1220 .web
1221 .bind_address
1222 .rsplit(':')
1223 .next()
1224 .and_then(|p| p.parse::<u16>().ok())
1225 .unwrap_or(4317);
1226 let has_api_key = !state.api_keys.is_empty();
1227 let template = SplashTemplate {
1228 csp_nonce,
1229 server_mode: state.server_mode,
1230 lan_ip,
1231 port,
1232 version: env!("CARGO_PKG_VERSION"),
1233 has_api_key,
1234 };
1235 Html(
1236 template
1237 .render()
1238 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1239 )
1240}
1241
1242async fn index(
1243 State(state): State<AppState>,
1244 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1245 Query(query): Query<IndexQuery>,
1246) -> impl IntoResponse {
1247 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1248 let policy = query
1249 .mixed_line_policy
1250 .unwrap_or_else(|| "code_only".to_string());
1251 let behavior = query
1252 .binary_file_behavior
1253 .unwrap_or_else(|| "skip".to_string());
1254 let cfg = ScanConfig {
1255 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1256 path: query.path.unwrap_or_default(),
1257 include_globs: query.include_globs.unwrap_or_default(),
1258 exclude_globs: query.exclude_globs.unwrap_or_default(),
1259 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1260 mixed_line_policy: policy,
1261 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1262 != Some("off"),
1263 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1264 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1265 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1266 != Some("disabled"),
1267 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1268 binary_file_behavior: behavior,
1269 output_dir: query.output_dir.unwrap_or_default(),
1270 report_title: query.report_title.unwrap_or_default(),
1271 generate_html: query.generate_html.as_deref() != Some("off"),
1272 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1273 };
1274 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1275 } else {
1276 "{}".to_string()
1277 };
1278
1279 let git_repo = query.git_repo.unwrap_or_default();
1280 let git_ref = query.git_ref.unwrap_or_default();
1281
1282 let git_label = make_git_label(&git_repo, &git_ref);
1283 let git_output_dir = if git_label.is_empty() {
1284 String::new()
1285 } else {
1286 desktop_dir().join(&git_label).display().to_string()
1287 };
1288 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1289 let git_output_dir_json =
1290 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1291
1292 let template = IndexTemplate {
1293 version: env!("CARGO_PKG_VERSION"),
1294 prefill_json,
1295 csp_nonce,
1296 git_repo,
1297 git_ref,
1298 git_label_json,
1299 git_output_dir_json,
1300 server_mode: state.server_mode,
1301 };
1302
1303 Html(
1304 template
1305 .render()
1306 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1307 )
1308}
1309
1310async fn scan_setup_handler(
1311 State(state): State<AppState>,
1312 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1313) -> impl IntoResponse {
1314 let recent_scans_json = {
1315 let arr: Vec<serde_json::Value> = {
1316 let reg = state.registry.lock().await;
1317 reg.entries
1318 .iter()
1319 .rev()
1320 .take(6)
1321 .map(|e| {
1322 let run_dir = e
1323 .html_path
1324 .as_ref()
1325 .or(e.json_path.as_ref())
1326 .and_then(|p| p.parent().map(PathBuf::from));
1327 let config_val: Option<serde_json::Value> = run_dir
1328 .and_then(|d| find_scan_config_in_dir(&d))
1329 .and_then(|p| fs::read_to_string(&p).ok())
1330 .and_then(|s| serde_json::from_str(&s).ok());
1331 serde_json::json!({
1332 "project_label": e.project_label,
1333 "timestamp": fmt_la_time(e.timestamp_utc),
1334 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1335 "config": config_val,
1336 })
1337 })
1338 .collect()
1339 };
1340 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1341 };
1342
1343 let template = ScanSetupTemplate {
1344 version: env!("CARGO_PKG_VERSION"),
1345 recent_scans_json,
1346 csp_nonce,
1347 };
1348 Html(
1349 template
1350 .render()
1351 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1352 )
1353}
1354
1355async fn healthz() -> &'static str {
1356 "ok"
1357}
1358
1359async fn api_version_handler() -> impl IntoResponse {
1360 axum::Json(serde_json::json!({
1361 "name": "oxide-sloc",
1362 "version": env!("CARGO_PKG_VERSION"),
1363 }))
1364}
1365
1366static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1367
1368async fn openapi_yaml_handler() -> impl IntoResponse {
1369 (
1370 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1371 OPENAPI_YAML,
1372 )
1373}
1374
1375async fn api_docs_handler(
1376 State(state): State<AppState>,
1377 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1378) -> impl IntoResponse {
1379 let has_api_key = !state.api_keys.is_empty();
1380 Html(
1381 ApiDocsTemplate {
1382 has_api_key,
1383 csp_nonce,
1384 version: env!("CARGO_PKG_VERSION"),
1385 }
1386 .render()
1387 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1388 )
1389}
1390
1391async fn chart_js_handler() -> impl IntoResponse {
1392 (
1393 [(
1394 header::CONTENT_TYPE,
1395 "application/javascript; charset=utf-8",
1396 )],
1397 CHART_JS,
1398 )
1399}
1400
1401#[derive(Debug, Deserialize)]
1402struct AnalyzeForm {
1403 path: String,
1404 git_repo: Option<String>,
1405 git_ref: Option<String>,
1406 mixed_line_policy: Option<MixedLinePolicy>,
1407 python_docstrings_as_comments: Option<String>,
1408 generated_file_detection: Option<String>,
1409 minified_file_detection: Option<String>,
1410 vendor_directory_detection: Option<String>,
1411 include_lockfiles: Option<String>,
1412 binary_file_behavior: Option<BinaryFileBehavior>,
1413 output_dir: Option<String>,
1414 report_title: Option<String>,
1415 report_header_footer: Option<String>,
1416 generate_html: Option<String>,
1417 generate_pdf: Option<String>,
1418 include_globs: Option<String>,
1419 exclude_globs: Option<String>,
1420 submodule_breakdown: Option<String>,
1421 coverage_file: Option<String>,
1422 continuation_line_policy: Option<ContinuationLinePolicy>,
1423 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1424 count_compiler_directives: Option<String>,
1425}
1426
1427#[allow(clippy::struct_excessive_bools)]
1428#[derive(Debug, Serialize, Deserialize, Clone)]
1429struct ScanConfig {
1430 oxide_sloc_version: String,
1431 path: String,
1432 include_globs: String,
1433 exclude_globs: String,
1434 submodule_breakdown: bool,
1435 mixed_line_policy: String,
1436 python_docstrings_as_comments: bool,
1437 generated_file_detection: bool,
1438 minified_file_detection: bool,
1439 vendor_directory_detection: bool,
1440 include_lockfiles: bool,
1441 binary_file_behavior: String,
1442 output_dir: String,
1443 report_title: String,
1444 generate_html: bool,
1445 generate_pdf: bool,
1446}
1447
1448#[derive(Debug, Deserialize, Default)]
1449struct IndexQuery {
1450 path: Option<String>,
1451 include_globs: Option<String>,
1452 exclude_globs: Option<String>,
1453 submodule_breakdown: Option<String>,
1454 mixed_line_policy: Option<String>,
1455 python_docstrings_as_comments: Option<String>,
1456 generated_file_detection: Option<String>,
1457 minified_file_detection: Option<String>,
1458 vendor_directory_detection: Option<String>,
1459 include_lockfiles: Option<String>,
1460 binary_file_behavior: Option<String>,
1461 output_dir: Option<String>,
1462 report_title: Option<String>,
1463 generate_html: Option<String>,
1464 generate_pdf: Option<String>,
1465 prefilled: Option<String>,
1466 git_repo: Option<String>,
1467 git_ref: Option<String>,
1468}
1469
1470#[derive(Debug, Deserialize)]
1471struct PreviewQuery {
1472 path: Option<String>,
1473 include_globs: Option<String>,
1474 exclude_globs: Option<String>,
1475}
1476
1477#[cfg(feature = "native-dialog")]
1478#[derive(Debug, Deserialize)]
1479struct PickDirectoryQuery {
1480 kind: Option<String>,
1481 current: Option<String>,
1482}
1483
1484#[cfg(not(feature = "native-dialog"))]
1485#[derive(Debug, Deserialize)]
1486struct PickDirectoryQuery {}
1487
1488#[derive(Debug, Deserialize, Default)]
1489struct ArtifactQuery {
1490 download: Option<String>,
1491}
1492
1493#[cfg(feature = "native-dialog")]
1494#[derive(Debug, Serialize)]
1495struct PickDirectoryResponse {
1496 selected_path: Option<String>,
1497 cancelled: bool,
1498}
1499
1500#[cfg(feature = "native-dialog")]
1501async fn pick_directory_handler(
1502 State(state): State<AppState>,
1503 Query(query): Query<PickDirectoryQuery>,
1504) -> Response {
1505 if state.server_mode {
1506 return StatusCode::NOT_FOUND.into_response();
1507 }
1508
1509 let is_coverage = query.kind.as_deref() == Some("coverage");
1510 let title = match query.kind.as_deref() {
1511 Some("output") => "Select output directory",
1512 Some("reports") => "Select folder containing saved reports",
1513 Some("coverage") => "Select LCOV coverage file",
1514 _ => "Select project directory",
1515 }
1516 .to_owned();
1517 let current = query.current.clone();
1518
1519 let picked = tokio::task::spawn_blocking(move || {
1520 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1523 let fg_tid = win_dialog_focus::attach_to_foreground();
1524 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1525 win_dialog_focus::flash_dialog_when_ready(title.clone());
1526
1527 let mut dialog = rfd::FileDialog::new().set_title(&title);
1528 if let Some(current) = current.as_deref() {
1529 let resolved = resolve_input_path(current);
1530 let seed = if resolved.is_dir() {
1531 Some(resolved)
1532 } else {
1533 resolved.parent().map(Path::to_path_buf)
1534 };
1535 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1536 dialog = dialog.set_directory(seed_dir);
1537 }
1538 }
1539 let result = if is_coverage {
1540 dialog
1541 .add_filter(
1542 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1543 &["info", "lcov", "xml"],
1544 )
1545 .pick_file()
1546 } else {
1547 dialog.pick_folder()
1548 };
1549
1550 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1551 win_dialog_focus::detach_from_foreground(fg_tid);
1552
1553 result
1554 })
1555 .await
1556 .unwrap_or(None);
1557
1558 Json(PickDirectoryResponse {
1559 selected_path: picked.as_ref().map(|p| display_path(p)),
1560 cancelled: picked.is_none(),
1561 })
1562 .into_response()
1563}
1564
1565#[cfg(not(feature = "native-dialog"))]
1566async fn pick_directory_handler(
1567 State(_state): State<AppState>,
1568 Query(_query): Query<PickDirectoryQuery>,
1569) -> Response {
1570 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1571}
1572
1573#[cfg(feature = "native-dialog")]
1574async fn pick_file_handler(State(state): State<AppState>) -> Response {
1575 if state.server_mode {
1576 return StatusCode::NOT_FOUND.into_response();
1577 }
1578 let picked = tokio::task::spawn_blocking(|| {
1579 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1580 let fg_tid = win_dialog_focus::attach_to_foreground();
1581 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1582 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1583
1584 let result = rfd::FileDialog::new()
1585 .set_title("Select HTML report")
1586 .add_filter("HTML report", &["html"])
1587 .pick_file();
1588
1589 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1590 win_dialog_focus::detach_from_foreground(fg_tid);
1591
1592 result
1593 })
1594 .await
1595 .unwrap_or(None);
1596 Json(PickDirectoryResponse {
1597 selected_path: picked.as_ref().map(|p| display_path(p)),
1598 cancelled: picked.is_none(),
1599 })
1600 .into_response()
1601}
1602
1603#[cfg(not(feature = "native-dialog"))]
1604async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1605 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1606}
1607
1608fn is_upload_tmp_path(path: &Path) -> bool {
1613 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1614 path.starts_with(&upload_root)
1615}
1616
1617fn is_sample_path(path: &Path) -> bool {
1620 let root = workspace_root();
1621 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1622}
1623
1624fn upload_base_dir() -> PathBuf {
1626 std::env::temp_dir().join("oxide-sloc-uploads")
1627}
1628
1629fn upload_staging_path(id: &str) -> PathBuf {
1631 upload_base_dir().join(id)
1632}
1633
1634#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1638 const MAX_FILES: usize = 50_000;
1639 if body.files.is_empty() {
1640 return Err((
1641 StatusCode::BAD_REQUEST,
1642 Json(serde_json::json!({"error": "No files received"})),
1643 )
1644 .into_response());
1645 }
1646 if body.files.len() > MAX_FILES {
1647 return Err((
1648 StatusCode::PAYLOAD_TOO_LARGE,
1649 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1650 )
1651 .into_response());
1652 }
1653 Ok(())
1654}
1655
1656fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1659 match id {
1660 Some(id)
1661 if !id.is_empty()
1662 && id.len() <= 36
1663 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1664 {
1665 (id.to_string(), upload_staging_path(id))
1666 }
1667 _ => {
1668 let new_id = uuid::Uuid::new_v4().to_string();
1669 let staging = upload_staging_path(&new_id);
1670 (new_id, staging)
1671 }
1672 }
1673}
1674
1675#[allow(clippy::result_large_err)]
1680async fn stage_decoded_entry(
1681 entry: &UploadedFile,
1682 staging: &Path,
1683 total_bytes: &mut usize,
1684 project_root: &mut Option<PathBuf>,
1685) -> Result<(), Response> {
1686 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1687
1688 let Ok(data) = base64::Engine::decode(
1689 &base64::engine::general_purpose::STANDARD,
1690 entry.content.as_bytes(),
1691 ) else {
1692 return Ok(());
1693 };
1694
1695 *total_bytes += data.len();
1696 if *total_bytes > MAX_TOTAL_BYTES {
1697 return Err((
1698 StatusCode::PAYLOAD_TOO_LARGE,
1699 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1700 )
1701 .into_response());
1702 }
1703
1704 let rel = std::path::Path::new(&entry.path);
1705 if project_root.is_none() {
1706 if let Some(first) = rel.components().next() {
1707 *project_root = Some(staging.join(first.as_os_str()));
1708 }
1709 }
1710
1711 let dest = staging.join(rel);
1712 if let Some(parent) = dest.parent() {
1713 if tokio::fs::create_dir_all(parent).await.is_err() {
1714 return Err((
1715 StatusCode::INTERNAL_SERVER_ERROR,
1716 Json(serde_json::json!({"error": "Failed to create directory structure"})),
1717 )
1718 .into_response());
1719 }
1720 }
1721
1722 if tokio::fs::write(&dest, &data).await.is_err() {
1723 return Err((
1724 StatusCode::INTERNAL_SERVER_ERROR,
1725 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1726 )
1727 .into_response());
1728 }
1729
1730 Ok(())
1731}
1732
1733async fn write_upload_files(
1737 files: &[UploadedFile],
1738 staging: &Path,
1739 upload_id: &str,
1740) -> Result<(usize, Option<PathBuf>), Response> {
1741 let mut total_bytes: usize = 0;
1742 let mut project_root: Option<PathBuf> = None;
1743 let mut traversal_attempts: usize = 0;
1744
1745 for entry in files {
1746 let rel = std::path::Path::new(&entry.path);
1747 if rel
1748 .components()
1749 .any(|c| matches!(c, std::path::Component::ParentDir))
1750 {
1751 traversal_attempts += 1;
1752 if traversal_attempts >= 5 {
1753 let _ = tokio::fs::remove_dir_all(staging).await;
1754 tracing::warn!(
1755 event = "upload_path_traversal",
1756 upload_id = %upload_id,
1757 "Upload rejected: repeated path traversal attempts detected"
1758 );
1759 return Err((
1760 StatusCode::BAD_REQUEST,
1761 Json(serde_json::json!({"error": "Upload rejected"})),
1762 )
1763 .into_response());
1764 }
1765 continue;
1766 }
1767
1768 if let Err(resp) =
1769 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
1770 {
1771 let _ = tokio::fs::remove_dir_all(staging).await;
1772 return Err(resp);
1773 }
1774 }
1775
1776 Ok((files.len(), project_root))
1777}
1778
1779fn parse_tarball_size_caps() -> (u64, u64) {
1782 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
1783 .ok()
1784 .and_then(|v| v.parse().ok())
1785 .unwrap_or(2048_u64)
1786 * 1024
1787 * 1024;
1788 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
1789 .ok()
1790 .and_then(|v| v.parse().ok())
1791 .unwrap_or(10_240_u64)
1792 * 1024
1793 * 1024;
1794 (compressed, decompressed)
1795}
1796
1797#[allow(clippy::result_large_err)] async fn stream_body_to_file(
1802 body: axum::body::Body,
1803 dest_path: &Path,
1804 max_bytes: u64,
1805) -> Result<u64, Response> {
1806 use http_body_util::BodyExt as _;
1807 use tokio::io::AsyncWriteExt as _;
1808
1809 let mut file = match tokio::fs::File::create(dest_path).await {
1810 Ok(f) => f,
1811 Err(e) => {
1812 tracing::error!(
1813 event = "upload_io_error",
1814 "failed to create tarball temp file: {e}"
1815 );
1816 return Err((
1817 StatusCode::INTERNAL_SERVER_ERROR,
1818 Json(serde_json::json!({"error": "Upload initialization failed"})),
1819 )
1820 .into_response());
1821 }
1822 };
1823
1824 let mut body = body;
1825 let mut written: u64 = 0;
1826 loop {
1827 match body.frame().await {
1828 None => break,
1829 Some(Err(e)) => {
1830 let _ = tokio::fs::remove_file(dest_path).await;
1831 return Err((
1832 StatusCode::BAD_REQUEST,
1833 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
1834 )
1835 .into_response());
1836 }
1837 Some(Ok(frame)) => {
1838 if let Ok(data) = frame.into_data() {
1839 written += data.len() as u64;
1840 if written > max_bytes {
1841 let _ = tokio::fs::remove_file(dest_path).await;
1842 return Err((
1843 StatusCode::PAYLOAD_TOO_LARGE,
1844 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
1845 )
1846 .into_response());
1847 }
1848 if let Err(e) = file.write_all(&data).await {
1849 let _ = tokio::fs::remove_file(dest_path).await;
1850 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
1851 return Err((
1852 StatusCode::INTERNAL_SERVER_ERROR,
1853 Json(serde_json::json!({"error": "Upload write failed"})),
1854 )
1855 .into_response());
1856 }
1857 }
1858 }
1859 }
1860 }
1861 drop(file);
1862 Ok(written)
1863}
1864
1865#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
1870 tarball_path: &Path,
1871 staging: &Path,
1872 max_decompressed_bytes: u64,
1873) -> Result<(), Response> {
1874 let staging_clone = staging.to_path_buf();
1875 let tarball_clone = tarball_path.to_path_buf();
1876 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
1877 let file = std::fs::File::open(&tarball_clone)?;
1878 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
1879 let limited = SizeLimitReader {
1880 inner: gz,
1881 remaining: max_decompressed_bytes,
1882 };
1883 let mut archive = tar::Archive::new(limited);
1884 archive.set_overwrite(true);
1885 archive.set_preserve_permissions(false);
1886 std::fs::create_dir_all(&staging_clone)?;
1887 archive.unpack(&staging_clone)?;
1888 Ok(())
1889 })
1890 .await;
1891 let _ = tokio::fs::remove_file(tarball_path).await;
1892
1893 match extract_result {
1894 Ok(Ok(())) => Ok(()),
1895 Ok(Err(e)) => {
1896 let _ = tokio::fs::remove_dir_all(staging).await;
1897 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
1898 tracing::warn!(
1899 event = "upload_extract_error",
1900 "tarball extraction failed: {e:#}"
1901 );
1902 let (status, msg) = if is_size_limit {
1903 (
1904 StatusCode::PAYLOAD_TOO_LARGE,
1905 "Archive exceeds the decompressed size limit",
1906 )
1907 } else {
1908 (StatusCode::BAD_REQUEST, "Failed to extract archive")
1909 };
1910 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
1911 }
1912 Err(e) => {
1913 let _ = tokio::fs::remove_dir_all(staging).await;
1914 tracing::error!(
1915 event = "upload_extract_panic",
1916 "tarball extraction task panicked: {e}"
1917 );
1918 Err((
1919 StatusCode::INTERNAL_SERVER_ERROR,
1920 Json(serde_json::json!({"error": "Archive extraction failed"})),
1921 )
1922 .into_response())
1923 }
1924 }
1925}
1926
1927async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
1931 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
1932 let first = entries.next_entry().await.ok()??;
1933 if !first.path().is_dir() {
1934 return None;
1935 }
1936 if entries.next_entry().await.unwrap_or(None).is_some() {
1937 return None;
1938 }
1939 Some(first.path())
1940}
1941
1942#[derive(Deserialize)]
1949struct UploadDirRequest {
1950 files: Vec<UploadedFile>,
1951 upload_id: Option<String>,
1954}
1955
1956#[derive(Deserialize)]
1957struct UploadedFile {
1958 path: String,
1960 content: String,
1962}
1963
1964async fn upload_directory_handler(
1974 State(state): State<AppState>,
1975 Json(body): Json<UploadDirRequest>,
1976) -> Response {
1977 if !state.server_mode {
1978 return StatusCode::NOT_FOUND.into_response();
1979 }
1980 if let Err(resp) = validate_upload_dir_request(&body) {
1981 return resp;
1982 }
1983 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
1986 match write_upload_files(&body.files, &staging, &upload_id).await {
1987 Ok((file_count, project_root)) => {
1988 let scan_root = project_root.unwrap_or_else(|| staging.clone());
1989 Json(serde_json::json!({
1990 "tmp_path": scan_root.to_string_lossy(),
1991 "file_count": file_count,
1992 "upload_id": upload_id.clone()
1993 }))
1994 .into_response()
1995 }
1996 Err(resp) => resp,
1997 }
1998}
1999
2000#[derive(Deserialize)]
2002struct UploadFileRequest {
2003 filename: String,
2005 content: String,
2007}
2008
2009async fn upload_file_handler(
2015 State(state): State<AppState>,
2016 Json(body): Json<UploadFileRequest>,
2017) -> Response {
2018 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2021 return StatusCode::NOT_FOUND.into_response();
2022 }
2023
2024 let Ok(data) = base64::Engine::decode(
2025 &base64::engine::general_purpose::STANDARD,
2026 body.content.as_bytes(),
2027 ) else {
2028 return (
2029 StatusCode::BAD_REQUEST,
2030 Json(serde_json::json!({"error": "Invalid base64 content"})),
2031 )
2032 .into_response();
2033 };
2034
2035 if data.len() > MAX_FILE_BYTES {
2036 return (
2037 StatusCode::PAYLOAD_TOO_LARGE,
2038 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2039 )
2040 .into_response();
2041 }
2042
2043 let filename = std::path::Path::new(&body.filename)
2045 .file_name()
2046 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2047
2048 let upload_id = uuid::Uuid::new_v4();
2049 let staging = std::env::temp_dir()
2050 .join("oxide-sloc-uploads")
2051 .join(upload_id.to_string());
2052
2053 if tokio::fs::create_dir_all(&staging).await.is_err() {
2054 return (
2055 StatusCode::INTERNAL_SERVER_ERROR,
2056 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2057 )
2058 .into_response();
2059 }
2060
2061 let dest = staging.join(&filename);
2062 if tokio::fs::write(&dest, &data).await.is_err() {
2063 let _ = tokio::fs::remove_dir_all(&staging).await;
2064 return (
2065 StatusCode::INTERNAL_SERVER_ERROR,
2066 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2067 )
2068 .into_response();
2069 }
2070
2071 Json(serde_json::json!({
2072 "tmp_path": dest.to_string_lossy(),
2073 "upload_id": upload_id.to_string()
2074 }))
2075 .into_response()
2076}
2077
2078struct SizeLimitReader<R> {
2093 inner: R,
2094 remaining: u64,
2095}
2096impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2097 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2098 if self.remaining == 0 {
2099 return Err(std::io::Error::other("decompressed size limit exceeded"));
2100 }
2101 let n = self.inner.read(buf)?;
2102 self.remaining = self.remaining.saturating_sub(n as u64);
2103 Ok(n)
2104 }
2105}
2106
2107async fn upload_tarball_handler(
2108 State(state): State<AppState>,
2109 request: axum::extract::Request,
2110) -> Response {
2111 if !state.server_mode {
2112 return StatusCode::NOT_FOUND.into_response();
2113 }
2114
2115 let upload_id = uuid::Uuid::new_v4().to_string();
2116 let upload_base = upload_base_dir();
2117 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2118 let staging = upload_staging_path(&upload_id);
2119 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2120
2121 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2122 tracing::error!(
2123 event = "upload_io_error",
2124 "failed to create upload base dir: {e}"
2125 );
2126 return (
2127 StatusCode::INTERNAL_SERVER_ERROR,
2128 Json(serde_json::json!({"error": "Upload initialization failed"})),
2129 )
2130 .into_response();
2131 }
2132
2133 let compressed_bytes =
2135 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2136 Ok(n) => n,
2137 Err(resp) => return resp,
2138 };
2139
2140 if let Err(resp) =
2142 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2143 {
2144 return resp;
2145 }
2146
2147 let scan_root = find_single_top_dir(&staging)
2152 .await
2153 .unwrap_or_else(|| staging.clone());
2154
2155 let original_bytes = tokio::task::spawn_blocking({
2157 let p = scan_root.clone();
2158 move || dir_size_bytes(&p)
2159 })
2160 .await
2161 .unwrap_or(0);
2162
2163 Json(serde_json::json!({
2164 "tmp_path": scan_root.to_string_lossy(),
2165 "upload_id": upload_id,
2166 "compressed_bytes": compressed_bytes,
2167 "original_bytes": original_bytes,
2168 }))
2169 .into_response()
2170}
2171
2172#[derive(Deserialize)]
2173struct LocateReportForm {
2174 file_path: String,
2175}
2176
2177fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2179 let html = ErrorTemplate {
2180 message: message.into(),
2181 last_report_url: Some("/view-reports".to_string()),
2182 last_report_label: Some("View Reports".to_string()),
2183 csp_nonce: csp_nonce.to_owned(),
2184 version: env!("CARGO_PKG_VERSION"),
2185 }
2186 .render()
2187 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2188 Html(html).into_response()
2189}
2190
2191fn registry_entry_from_run(
2193 run: &AnalysisRun,
2194 json_path: PathBuf,
2195 html_path: PathBuf,
2196) -> RegistryEntry {
2197 let project_label = run.input_roots.first().map_or_else(
2198 || "Unknown Project".to_string(),
2199 |r| sanitize_project_label(r),
2200 );
2201 RegistryEntry {
2202 run_id: run.tool.run_id.clone(),
2203 timestamp_utc: run.tool.timestamp_utc,
2204 project_label,
2205 input_roots: run.input_roots.clone(),
2206 json_path: Some(json_path),
2207 html_path: Some(html_path),
2208 pdf_path: None,
2209 summary: ScanSummarySnapshot {
2210 files_analyzed: run.summary_totals.files_analyzed,
2211 files_skipped: run.summary_totals.files_skipped,
2212 total_physical_lines: run.summary_totals.total_physical_lines,
2213 code_lines: run.summary_totals.code_lines,
2214 comment_lines: run.summary_totals.comment_lines,
2215 blank_lines: run.summary_totals.blank_lines,
2216 functions: run.summary_totals.functions,
2217 classes: run.summary_totals.classes,
2218 variables: run.summary_totals.variables,
2219 imports: run.summary_totals.imports,
2220 test_count: run.summary_totals.test_count,
2221 coverage_lines_found: run.summary_totals.coverage_lines_found,
2222 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2223 coverage_functions_found: run.summary_totals.coverage_functions_found,
2224 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2225 coverage_branches_found: run.summary_totals.coverage_branches_found,
2226 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2227 },
2228 csv_path: None,
2229 xlsx_path: None,
2230 git_branch: None,
2231 git_commit: None,
2232 git_author: None,
2233 git_tags: None,
2234 git_nearest_tag: None,
2235 git_commit_date: None,
2236 }
2237}
2238
2239pub(crate) async fn register_artifacts_in_registry(
2242 state: &AppState,
2243 label: &str,
2244 run: &AnalysisRun,
2245 artifacts: &RunArtifacts,
2246) {
2247 let Some(json_path) = artifacts.json_path.clone() else {
2248 return;
2249 };
2250 let Some(html_path) = artifacts.html_path.clone() else {
2251 return;
2252 };
2253 let mut entry = registry_entry_from_run(run, json_path, html_path);
2254 entry.project_label = label.to_owned();
2255 let mut reg = state.registry.lock().await;
2256 reg.add_entry(entry);
2257 let _ = reg.save(&state.registry_path);
2258}
2259
2260#[allow(clippy::result_large_err)]
2265fn validate_locate_request(
2266 state: &AppState,
2267 file_path: &str,
2268 csp_nonce: &str,
2269) -> Result<(PathBuf, PathBuf), Response> {
2270 let file_ext = Path::new(file_path)
2271 .extension()
2272 .and_then(|e| e.to_str())
2273 .unwrap_or("")
2274 .to_ascii_lowercase();
2275 if file_ext != "html" {
2276 return Err(locate_report_error(
2277 "Only .html report files can be located via this form.",
2278 csp_nonce,
2279 ));
2280 }
2281 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
2282 Ok(p) => strip_unc_prefix(p),
2283 Err(_) => {
2284 return Err(locate_report_error(
2285 "Report file not found or path is invalid.",
2286 csp_nonce,
2287 ));
2288 }
2289 };
2290 if state.server_mode {
2291 let output_root = resolve_output_root(None);
2292 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2293 if !html_path.starts_with(&canonical_root) {
2294 return Err(locate_report_error(
2295 "Report file must be within the configured output directory.",
2296 csp_nonce,
2297 ));
2298 }
2299 }
2300 let parent = match html_path.parent() {
2301 Some(p) => p.to_path_buf(),
2302 None => {
2303 return Err(locate_report_error(
2304 "Report file has no parent directory.",
2305 csp_nonce,
2306 ));
2307 }
2308 };
2309 Ok((html_path, parent))
2310}
2311
2312fn locate_path_hint(server_mode: bool, path: &Path) -> String {
2314 if server_mode {
2315 String::new()
2316 } else {
2317 format!("\n\nFile: {}", path.display())
2318 }
2319}
2320
2321async fn locate_report_handler(
2322 State(state): State<AppState>,
2323 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2324 Form(form): Form<LocateReportForm>,
2325) -> impl IntoResponse {
2326 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2327 Ok(v) => v,
2328 Err(resp) => return resp,
2329 };
2330
2331 let json_candidate = parent.join("result.json");
2332 let mut reg = state.registry.lock().await;
2333 let entry_idx = reg.entries.iter().position(|e| {
2335 let json_match = e
2336 .json_path
2337 .as_ref()
2338 .and_then(|p| p.parent())
2339 .is_some_and(|p| p == parent);
2340 let html_match = e
2341 .html_path
2342 .as_ref()
2343 .and_then(|p| p.parent())
2344 .is_some_and(|p| p == parent);
2345 json_match || html_match
2346 });
2347 if let Some(idx) = entry_idx {
2348 reg.entries[idx].html_path = Some(html_path);
2349 let _ = reg.save(&state.registry_path);
2350 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2351 }
2352 if json_candidate.exists() {
2354 match read_json(&json_candidate) {
2355 Ok(run) => {
2356 let entry = registry_entry_from_run(&run, json_candidate, html_path);
2357 reg.add_entry(entry);
2358 let _ = reg.save(&state.registry_path);
2359 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2360 }
2361 Err(e) => {
2362 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
2363 let err_detail = if state.server_mode {
2364 String::new()
2365 } else {
2366 format!("\n\nError: {e}")
2367 };
2368 return locate_report_error(
2369 format!(
2370 "Could not link this report.\n\nA 'result.json' was found but could not \
2371 be parsed — it may have been saved by an older version of OxideSLOC. \
2372 Re-running the analysis will create a fresh, compatible \
2373 record.{file_hint}{err_detail}"
2374 ),
2375 &csp_nonce,
2376 );
2377 }
2378 }
2379 }
2380 drop(reg);
2381 let file_hint = locate_path_hint(state.server_mode, &html_path);
2382 locate_report_error(
2383 format!(
2384 "Could not link this report.\n\nNo matching scan record was found, and no \
2385 'result.json' was found in the same folder.{file_hint}"
2386 ),
2387 &csp_nonce,
2388 )
2389}
2390
2391fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2393 fs::read_dir(dir)
2394 .ok()?
2395 .flatten()
2396 .map(|e| e.path())
2397 .find(|p| {
2398 p.is_file()
2399 && p.file_stem()
2400 .and_then(|n| n.to_str())
2401 .is_some_and(|n| n.starts_with("result"))
2402 && p.extension()
2403 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2404 })
2405}
2406
2407#[derive(Deserialize)]
2408struct LocateReportsDirForm {
2409 folder_path: String,
2410}
2411
2412#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
2414 State(state): State<AppState>,
2415 Form(form): Form<LocateReportsDirForm>,
2416) -> impl IntoResponse {
2417 if state.server_mode {
2418 return StatusCode::NOT_FOUND.into_response();
2419 }
2420 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2421 Ok(p) => strip_unc_prefix(p),
2422 Err(_) => {
2423 return axum::response::Redirect::to(
2424 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2425 )
2426 .into_response();
2427 }
2428 };
2429 if !folder.is_dir() {
2430 return axum::response::Redirect::to(
2431 "/view-reports?error=Selected+path+is+not+a+directory.",
2432 )
2433 .into_response();
2434 }
2435
2436 let candidates = collect_result_json_candidates(&folder);
2437
2438 if candidates.is_empty() {
2439 return axum::response::Redirect::to(
2440 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2441 )
2442 .into_response();
2443 }
2444
2445 let mut linked_count: usize = 0;
2446 let mut reg = state.registry.lock().await;
2447 for json_path in candidates {
2448 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2449 continue;
2450 };
2451 if is_dir_already_registered(®, &parent) {
2452 continue;
2453 }
2454 let Some(entry) = build_registry_entry_from_json(json_path) else {
2455 continue;
2456 };
2457 reg.add_entry(entry);
2458 linked_count += 1;
2459 }
2460 let _ = reg.save(&state.registry_path);
2461 drop(reg);
2462
2463 if linked_count == 0 {
2464 return axum::response::Redirect::to(
2465 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2466 )
2467 .into_response();
2468 }
2469 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2470}
2471
2472#[derive(Deserialize)]
2473struct RelocateScanForm {
2474 run_id: String,
2475 folder_path: String,
2476 redirect_url: String,
2477}
2478
2479async fn relocate_scan_handler(
2480 State(state): State<AppState>,
2481 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2482 Form(form): Form<RelocateScanForm>,
2483) -> impl IntoResponse {
2484 if state.server_mode {
2485 return StatusCode::NOT_FOUND.into_response();
2486 }
2487
2488 let run_id = form.run_id.trim().to_string();
2489 let redirect_url = form.redirect_url.trim().to_string();
2490
2491 let run_exists = {
2492 let reg = state.registry.lock().await;
2493 reg.find_by_run_id(&run_id).is_some()
2494 };
2495 if !run_exists {
2496 let html = ErrorTemplate {
2497 message: format!("Run ID '{run_id}' not found in registry."),
2498 last_report_url: Some("/compare-scans".to_string()),
2499 last_report_label: Some("Compare Scans".to_string()),
2500 csp_nonce: csp_nonce.clone(),
2501 version: env!("CARGO_PKG_VERSION"),
2502 }
2503 .render()
2504 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2505 return Html(html).into_response();
2506 }
2507
2508 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2509 Ok(p) => strip_unc_prefix(p),
2510 Err(_) => {
2511 return missing_scan_relocate_response(
2512 "Folder not found or path is invalid.",
2513 &run_id,
2514 form.folder_path.trim(),
2515 &redirect_url,
2516 false,
2517 &csp_nonce,
2518 );
2519 }
2520 };
2521 if !folder.is_dir() {
2522 return missing_scan_relocate_response(
2523 "Selected path is not a directory.",
2524 &run_id,
2525 &folder.display().to_string(),
2526 &redirect_url,
2527 false,
2528 &csp_nonce,
2529 );
2530 }
2531
2532 let json_candidates = find_result_files_by_ext(&folder, "json");
2533 if json_candidates.is_empty() {
2534 return missing_scan_relocate_response(
2535 &format!(
2536 "No result JSON files found in the selected folder.\nSearched: {}",
2537 folder.display()
2538 ),
2539 &run_id,
2540 &folder.display().to_string(),
2541 &redirect_url,
2542 false,
2543 &csp_nonce,
2544 );
2545 }
2546
2547 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
2548 return missing_scan_relocate_response(
2549 &format!(
2550 "No matching scan found in the selected folder.\n\
2551 The JSON files present do not contain run ID: {run_id}\n\
2552 Searched: {}",
2553 folder.display()
2554 ),
2555 &run_id,
2556 &folder.display().to_string(),
2557 &redirect_url,
2558 false,
2559 &csp_nonce,
2560 );
2561 };
2562
2563 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
2564 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
2565 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
2566
2567 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2568 redirect_url
2569 } else {
2570 "/compare-scans".to_string()
2571 };
2572 axum::response::Redirect::to(&safe_redirect).into_response()
2573}
2574
2575fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
2576 fs::read_dir(folder)
2577 .ok()
2578 .into_iter()
2579 .flatten()
2580 .flatten()
2581 .map(|e| e.path())
2582 .filter(|p| {
2583 p.is_file()
2584 && p.file_stem()
2585 .and_then(|n| n.to_str())
2586 .is_some_and(|n| n.starts_with("result"))
2587 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
2588 })
2589 .collect()
2590}
2591
2592fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
2593 candidates
2594 .iter()
2595 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
2596 .cloned()
2597}
2598
2599async fn update_run_file_paths(
2600 state: &AppState,
2601 run_id: &str,
2602 json_path: PathBuf,
2603 html_path: Option<PathBuf>,
2604 pdf_path: Option<PathBuf>,
2605) {
2606 let mut reg = state.registry.lock().await;
2607 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2608 entry.json_path = Some(json_path);
2609 if let Some(hp) = html_path {
2610 entry.html_path = Some(hp);
2611 }
2612 if let Some(pp) = pdf_path {
2613 entry.pdf_path = Some(pp);
2614 }
2615 }
2616 let _ = reg.save(&state.registry_path);
2617}
2618
2619fn missing_scan_relocate_response(
2620 message: &str,
2621 run_id: &str,
2622 folder_hint: &str,
2623 redirect_url: &str,
2624 server_mode: bool,
2625 csp_nonce: &str,
2626) -> axum::response::Response {
2627 let html = RelocateScanTemplate {
2628 message: message.to_string(),
2629 run_id: run_id.to_string(),
2630 folder_hint: folder_hint.to_string(),
2631 redirect_url: redirect_url.to_string(),
2632 server_mode,
2633 csp_nonce: csp_nonce.to_owned(),
2634 version: env!("CARGO_PKG_VERSION"),
2635 }
2636 .render()
2637 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2638 (StatusCode::NOT_FOUND, Html(html)).into_response()
2639}
2640
2641fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
2645 let mut candidates = Vec::new();
2646 if let Some(j) = find_result_json_in_dir(folder) {
2647 candidates.push(j);
2648 }
2649 if let Ok(dir_entries) = fs::read_dir(folder) {
2650 for entry in dir_entries.flatten() {
2651 let sub = entry.path();
2652 if sub.is_dir() {
2653 if let Some(j) = find_result_json_in_dir(&sub) {
2654 candidates.push(j);
2655 }
2656 }
2657 }
2658 }
2659 candidates
2660}
2661
2662fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
2663 reg.entries.iter().any(|e| {
2664 let dir_match = e
2665 .json_path
2666 .as_ref()
2667 .and_then(|p| p.parent())
2668 .is_some_and(|p| p == parent)
2669 || e.html_path
2670 .as_ref()
2671 .and_then(|p| p.parent())
2672 .is_some_and(|p| p == parent);
2673 dir_match
2674 && (e.json_path.as_ref().is_some_and(|p| p.exists())
2675 || e.html_path.as_ref().is_some_and(|p| p.exists()))
2676 })
2677}
2678
2679fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
2680 let parent = json_path.parent()?.to_path_buf();
2681 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2682 rd.flatten()
2683 .map(|e| e.path())
2684 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2685 });
2686 let run = read_json(&json_path).ok()?;
2687 let project_label = run.input_roots.first().map_or_else(
2688 || "Unknown Project".to_string(),
2689 |r| sanitize_project_label(r),
2690 );
2691 Some(RegistryEntry {
2692 run_id: run.tool.run_id.clone(),
2693 timestamp_utc: run.tool.timestamp_utc,
2694 project_label,
2695 input_roots: run.input_roots.clone(),
2696 json_path: Some(json_path),
2697 html_path,
2698 pdf_path: None,
2699 csv_path: None,
2700 xlsx_path: None,
2701 summary: ScanSummarySnapshot {
2702 files_analyzed: run.summary_totals.files_analyzed,
2703 files_skipped: run.summary_totals.files_skipped,
2704 total_physical_lines: run.summary_totals.total_physical_lines,
2705 code_lines: run.summary_totals.code_lines,
2706 comment_lines: run.summary_totals.comment_lines,
2707 blank_lines: run.summary_totals.blank_lines,
2708 functions: run.summary_totals.functions,
2709 classes: run.summary_totals.classes,
2710 variables: run.summary_totals.variables,
2711 imports: run.summary_totals.imports,
2712 test_count: run.summary_totals.test_count,
2713 coverage_lines_found: run.summary_totals.coverage_lines_found,
2714 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2715 coverage_functions_found: run.summary_totals.coverage_functions_found,
2716 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2717 coverage_branches_found: run.summary_totals.coverage_branches_found,
2718 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2719 },
2720 git_branch: run.git_branch.clone(),
2721 git_commit: run.git_commit_short.clone(),
2722 git_author: run.git_commit_author.clone(),
2723 git_tags: run.git_tags.clone(),
2724 git_nearest_tag: run.git_nearest_tag.clone(),
2725 git_commit_date: run.git_commit_date,
2726 })
2727}
2728
2729fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2732 let mut linked = 0usize;
2733 for json_path in collect_result_json_candidates(folder) {
2734 let Some(parent) = json_path.parent().map(PathBuf::from) else {
2735 continue;
2736 };
2737 if is_dir_already_registered(reg, &parent) {
2738 continue;
2739 }
2740 let Some(entry) = build_registry_entry_from_json(json_path) else {
2741 continue;
2742 };
2743 reg.add_entry(entry);
2744 linked += 1;
2745 }
2746 linked
2747}
2748
2749async fn auto_scan_watched_dirs(state: &AppState) {
2751 let dirs: Vec<PathBuf> = {
2752 let wd = state.watched_dirs.lock().await;
2753 wd.dirs.clone()
2754 };
2755 if dirs.is_empty() {
2756 return;
2757 }
2758 let mut reg = state.registry.lock().await;
2759 let mut total = 0usize;
2760 for dir in &dirs {
2761 if dir.is_dir() {
2762 total += scan_folder_into_registry(dir, &mut reg);
2763 }
2764 }
2765 if total > 0 {
2766 let _ = reg.save(&state.registry_path);
2767 }
2768}
2769
2770#[derive(Deserialize)]
2773struct WatchedDirForm {
2774 folder_path: String,
2775 #[serde(default = "default_redirect")]
2776 redirect_to: String,
2777}
2778
2779fn default_redirect() -> String {
2780 "/view-reports".to_string()
2781}
2782
2783#[derive(Deserialize)]
2784struct WatchedDirRefreshForm {
2785 #[serde(default = "default_redirect")]
2786 redirect_to: String,
2787}
2788
2789fn safe_redirect(dest: &str) -> &str {
2793 if dest.starts_with('/') {
2794 dest
2795 } else {
2796 "/"
2797 }
2798}
2799
2800async fn add_watched_dir_handler(
2803 State(state): State<AppState>,
2804 Form(form): Form<WatchedDirForm>,
2805) -> impl IntoResponse {
2806 if state.server_mode {
2807 return StatusCode::NOT_FOUND.into_response();
2808 }
2809 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2810 strip_unc_prefix(p)
2811 } else {
2812 let dest = format!(
2813 "{}?error=Folder+not+found+or+path+is+invalid.",
2814 safe_redirect(&form.redirect_to)
2815 );
2816 return axum::response::Redirect::to(&dest).into_response();
2817 };
2818 if !folder.is_dir() {
2819 let dest = format!(
2820 "{}?error=Selected+path+is+not+a+directory.",
2821 safe_redirect(&form.redirect_to)
2822 );
2823 return axum::response::Redirect::to(&dest).into_response();
2824 }
2825
2826 {
2828 let mut wd = state.watched_dirs.lock().await;
2829 wd.add(folder.clone());
2830 let _ = wd.save(&state.watched_dirs_path);
2831 }
2832
2833 let linked = {
2835 let mut reg = state.registry.lock().await;
2836 let n = scan_folder_into_registry(&folder, &mut reg);
2837 if n > 0 {
2838 let _ = reg.save(&state.registry_path);
2839 }
2840 n
2841 };
2842
2843 let dest = if linked > 0 {
2844 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2845 } else {
2846 format!(
2847 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2848 safe_redirect(&form.redirect_to)
2849 )
2850 };
2851 axum::response::Redirect::to(&dest).into_response()
2852}
2853
2854async fn remove_watched_dir_handler(
2855 State(state): State<AppState>,
2856 Form(form): Form<WatchedDirForm>,
2857) -> impl IntoResponse {
2858 if state.server_mode {
2859 return StatusCode::NOT_FOUND.into_response();
2860 }
2861 let folder = PathBuf::from(&form.folder_path);
2862 {
2863 let mut wd = state.watched_dirs.lock().await;
2864 wd.remove(&folder);
2865 let _ = wd.save(&state.watched_dirs_path);
2866 }
2867 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2868}
2869
2870async fn refresh_watched_dirs_handler(
2871 State(state): State<AppState>,
2872 Form(form): Form<WatchedDirRefreshForm>,
2873) -> impl IntoResponse {
2874 if state.server_mode {
2875 return StatusCode::NOT_FOUND.into_response();
2876 }
2877 let dirs: Vec<PathBuf> = {
2878 let wd = state.watched_dirs.lock().await;
2879 wd.dirs.clone()
2880 };
2881 let mut total = 0usize;
2882 {
2883 let mut reg = state.registry.lock().await;
2884 for dir in &dirs {
2885 if dir.is_dir() {
2886 total += scan_folder_into_registry(dir, &mut reg);
2887 }
2888 }
2889 if total > 0 {
2890 let _ = reg.save(&state.registry_path);
2891 }
2892 }
2893 let dest = if total > 0 {
2894 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2895 } else {
2896 safe_redirect(&form.redirect_to).to_owned()
2897 };
2898 axum::response::Redirect::to(&dest).into_response()
2899}
2900
2901#[derive(Debug, Deserialize)]
2902struct OpenPathQuery {
2903 path: Option<String>,
2904}
2905
2906async fn open_path_handler(
2907 State(state): State<AppState>,
2908 Query(query): Query<OpenPathQuery>,
2909) -> impl IntoResponse {
2910 if state.server_mode {
2911 return Json(serde_json::json!({
2912 "server_mode_disabled": true,
2913 "message": "Opening a path in the file manager is only available in local desktop mode."
2914 }))
2915 .into_response();
2916 }
2917 let raw = match query.path.as_deref() {
2918 Some(p) if !p.is_empty() => p,
2919 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2920 };
2921
2922 let target = match fs::canonicalize(raw) {
2926 Ok(canonical) if canonical.is_file() => match canonical.parent() {
2927 Some(p) => p.to_path_buf(),
2928 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2929 },
2930 Ok(canonical) if canonical.is_dir() => canonical,
2931 Ok(_) => {
2932 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2933 }
2934 Err(_) => {
2935 let mut ancestor = std::path::Path::new(raw);
2937 loop {
2938 match ancestor.parent() {
2939 Some(p) => {
2940 ancestor = p;
2941 if ancestor.is_dir() {
2942 break;
2943 }
2944 }
2945 None => {
2946 return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2947 .into_response();
2948 }
2949 }
2950 }
2951 ancestor.to_path_buf()
2952 }
2953 };
2954
2955 #[cfg(target_os = "windows")]
2956 {
2957 let ps_cmd = "Add-Type -TypeDefinition \
2961 'using System;using System.Runtime.InteropServices;\
2962 public class WF{\
2963 [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2964 [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2965 }'; \
2966 $p=$env:SLOC_OPEN_PATH; \
2967 $sh=New-Object -ComObject Shell.Application; \
2968 $sh.Open($p); \
2969 Start-Sleep -Milliseconds 600; \
2970 foreach($w in $sh.Windows()){ \
2971 try{ \
2972 if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2973 [System.IO.Path]::GetFullPath($p)){ \
2974 [WF]::ShowWindow($w.HWND,3); \
2975 [WF]::SetForegroundWindow($w.HWND); \
2976 break \
2977 } \
2978 }catch{} \
2979 }";
2980 let _ = std::process::Command::new("powershell")
2981 .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2982 .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2983 .stdout(Stdio::null())
2984 .stderr(Stdio::null())
2985 .spawn();
2986 }
2987 #[cfg(target_os = "macos")]
2988 let _ = std::process::Command::new("open")
2989 .arg(&target)
2990 .stdout(Stdio::null())
2991 .stderr(Stdio::null())
2992 .spawn();
2993 #[cfg(target_os = "linux")]
2994 let _ = std::process::Command::new("xdg-open")
2995 .arg(&target)
2996 .stdout(Stdio::null())
2997 .stderr(Stdio::null())
2998 .spawn();
2999
3000 (StatusCode::OK, "ok").into_response()
3001}
3002
3003async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3004 let (content_type, bytes): (&'static str, &'static [u8]) =
3005 match (folder.as_str(), file.as_str()) {
3006 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3007 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3008 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3009 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3010 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3011 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3012 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3013 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3014 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3015 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3016 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3017 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3018 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3019 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3020 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3021 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3022 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3023 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3024 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3025 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3026 _ => return StatusCode::NOT_FOUND.into_response(),
3027 };
3028 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3029}
3030
3031async fn preview_handler(
3032 State(state): State<AppState>,
3033 Query(query): Query<PreviewQuery>,
3034) -> impl IntoResponse {
3035 let raw_path = query
3036 .path
3037 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3038 let resolved = resolve_input_path(&raw_path);
3039
3040 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3044 return Html(
3045 r#"<div class="preview-error">Sample directory not available on this server.
3046 Enter a path to a project directory or upload files using Browse.</div>"#
3047 .to_string(),
3048 );
3049 }
3050
3051 if state.server_mode {
3052 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3053 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3055 let config = &state.base_config;
3056 if config.discovery.allowed_scan_roots.is_empty() {
3057 return Html(
3058 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3059 );
3060 }
3061 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3062 fs::canonicalize(root)
3063 .ok()
3064 .is_some_and(|r| canonical.starts_with(&r))
3065 });
3066 if !allowed {
3067 return Html(
3068 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3069 );
3070 }
3071 }
3072 }
3073
3074 let include_patterns = split_patterns(query.include_globs.as_deref());
3075 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3076
3077 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3078 Ok(html) => Html(html),
3079 Err(err) => Html(format!(
3080 r#"<div class="preview-error">Preview failed: {}</div>"#,
3081 escape_html(&err.to_string())
3082 )),
3083 }
3084}
3085
3086#[derive(Debug, Deserialize, Default)]
3087struct SuggestCoverageQuery {
3088 path: Option<String>,
3089}
3090
3091#[derive(Serialize)]
3092struct SuggestCoverageResponse {
3093 found: Option<String>,
3094 tool: Option<&'static str>,
3095 hint: Option<&'static str>,
3096}
3097
3098async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3099 const CANDIDATES: &[&str] = &[
3100 "coverage/lcov.info",
3102 "lcov.info",
3103 "target/llvm-cov/lcov.info",
3104 "target/coverage/lcov.info",
3105 "target/debug/coverage/lcov.info",
3106 "coverage/coverage.lcov",
3107 "build/coverage/lcov.info",
3108 "reports/lcov.info",
3109 "coverage.xml",
3111 "coverage/coverage.xml",
3112 "target/site/cobertura/coverage.xml",
3113 "build/reports/coverage/coverage.xml",
3114 "target/site/jacoco/jacoco.xml",
3116 "build/reports/jacoco/test/jacocoTestReport.xml",
3117 "build/reports/jacoco/jacocoTestReport.xml",
3118 "build/jacoco/jacoco.xml",
3119 ];
3120 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3121 let found = CANDIDATES
3122 .iter()
3123 .map(|rel| root.join(rel))
3124 .find(|p| p.is_file())
3125 .map(|p| display_path(&p));
3126
3127 let (tool, hint) = detect_coverage_tool(&root);
3128 Json(SuggestCoverageResponse { found, tool, hint })
3129}
3130
3131fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3134 if root.join("Cargo.toml").is_file() {
3135 return (
3136 Some("cargo-llvm-cov"),
3137 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3138 );
3139 }
3140 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3141 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3142 }
3143 if root.join("pom.xml").is_file() {
3144 return (Some("jacoco"), Some("mvn test jacoco:report"));
3145 }
3146 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3147 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3148 }
3149 (None, None)
3150}
3151
3152#[allow(clippy::result_large_err)]
3154fn validate_server_scan_path(
3155 config: &sloc_config::AppConfig,
3156 resolved_path: &Path,
3157 csp_nonce: &str,
3158) -> Result<(), Response> {
3159 if config.discovery.allowed_scan_roots.is_empty() {
3160 let template = ErrorTemplate {
3161 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3162 Set allowed_scan_roots in the server config to permit scanning."
3163 .to_string(),
3164 last_report_url: None,
3165 last_report_label: None,
3166 csp_nonce: csp_nonce.to_owned(),
3167 version: env!("CARGO_PKG_VERSION"),
3168 };
3169 return Err((
3170 StatusCode::FORBIDDEN,
3171 Html(
3172 template
3173 .render()
3174 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3175 ),
3176 )
3177 .into_response());
3178 }
3179 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3180 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3181 fs::canonicalize(root)
3182 .ok()
3183 .is_some_and(|r| canonical.starts_with(&r))
3184 });
3185 if !allowed {
3186 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3187 "Scan path not in allowed_scan_roots");
3188 let template = ErrorTemplate {
3189 message: "The requested path is not within an allowed scan directory.".to_string(),
3190 last_report_url: None,
3191 last_report_label: None,
3192 csp_nonce: csp_nonce.to_owned(),
3193 version: env!("CARGO_PKG_VERSION"),
3194 };
3195 return Err((
3196 StatusCode::FORBIDDEN,
3197 Html(
3198 template
3199 .render()
3200 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3201 ),
3202 )
3203 .into_response());
3204 }
3205 Ok(())
3206}
3207
3208fn apply_output_dir_exclusions(
3210 config: &mut sloc_config::AppConfig,
3211 project_path: &str,
3212 raw_output_dir: &str,
3213) {
3214 let project_root = resolve_input_path(project_path);
3215 let raw_out = raw_output_dir.trim();
3216 let resolved_out = if raw_out.is_empty() {
3217 project_root.join("sloc")
3218 } else if Path::new(raw_out).is_absolute() {
3219 PathBuf::from(raw_out)
3220 } else {
3221 workspace_root().join(raw_out)
3222 };
3223 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3224 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3225 let dir = first.to_string();
3226 if !config.discovery.excluded_directories.contains(&dir) {
3227 config.discovery.excluded_directories.push(dir);
3228 }
3229 }
3230 }
3231 if !config
3232 .discovery
3233 .excluded_directories
3234 .iter()
3235 .any(|d| d == "sloc")
3236 {
3237 config
3238 .discovery
3239 .excluded_directories
3240 .push("sloc".to_string());
3241 }
3242}
3243
3244const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3246 ScanSummarySnapshot {
3247 files_analyzed: run.summary_totals.files_analyzed,
3248 files_skipped: run.summary_totals.files_skipped,
3249 total_physical_lines: run.summary_totals.total_physical_lines,
3250 code_lines: run.summary_totals.code_lines,
3251 comment_lines: run.summary_totals.comment_lines,
3252 blank_lines: run.summary_totals.blank_lines,
3253 functions: run.summary_totals.functions,
3254 classes: run.summary_totals.classes,
3255 variables: run.summary_totals.variables,
3256 imports: run.summary_totals.imports,
3257 test_count: run.summary_totals.test_count,
3258 coverage_lines_found: run.summary_totals.coverage_lines_found,
3259 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3260 coverage_functions_found: run.summary_totals.coverage_functions_found,
3261 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3262 coverage_branches_found: run.summary_totals.coverage_branches_found,
3263 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3264 }
3265}
3266
3267pub(crate) fn build_run_registry_entry(
3269 run: &AnalysisRun,
3270 run_id: &str,
3271 project_label: &str,
3272 artifacts: &RunArtifacts,
3273) -> RegistryEntry {
3274 RegistryEntry {
3275 run_id: run_id.to_owned(),
3276 timestamp_utc: run.tool.timestamp_utc,
3277 project_label: project_label.to_owned(),
3278 input_roots: run.input_roots.clone(),
3279 json_path: artifacts.json_path.clone(),
3280 html_path: artifacts.html_path.clone(),
3281 pdf_path: artifacts.pdf_path.clone(),
3282 csv_path: artifacts.csv_path.clone(),
3283 xlsx_path: artifacts.xlsx_path.clone(),
3284 summary: summary_snapshot_from_run(run),
3285 git_branch: run.git_branch.clone(),
3286 git_commit: run.git_commit_short.clone(),
3287 git_author: run.git_commit_author.clone(),
3288 git_tags: run.git_tags.clone(),
3289 git_nearest_tag: run.git_nearest_tag.clone(),
3290 git_commit_date: run.git_commit_date.clone(),
3291 }
3292}
3293
3294fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3296 if let Some(policy) = form.mixed_line_policy {
3297 config.analysis.mixed_line_policy = policy;
3298 }
3299 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3300 config.analysis.generated_file_detection =
3301 form.generated_file_detection.as_deref() != Some("disabled");
3302 config.analysis.minified_file_detection =
3303 form.minified_file_detection.as_deref() != Some("disabled");
3304 config.analysis.vendor_directory_detection =
3305 form.vendor_directory_detection.as_deref() != Some("disabled");
3306 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3307 if let Some(binary_behavior) = form.binary_file_behavior {
3308 config.analysis.binary_file_behavior = binary_behavior;
3309 }
3310 if let Some(report_title) = form.report_title.as_deref() {
3311 let trimmed = report_title.trim();
3312 if !trimmed.is_empty() {
3313 config.reporting.report_title = trimmed.to_string();
3314 }
3315 }
3316 if let Some(hf) = form.report_header_footer.as_deref() {
3317 let trimmed = hf.trim();
3318 config.reporting.report_header_footer = if trimmed.is_empty() {
3319 None
3320 } else {
3321 Some(trimmed.to_string())
3322 };
3323 }
3324 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3325 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3326 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3327 if let Some(policy) = form.continuation_line_policy {
3328 config.analysis.continuation_line_policy = policy;
3329 }
3330 if let Some(policy) = form.blank_in_block_comment_policy {
3331 config.analysis.blank_in_block_comment_policy = policy;
3332 }
3333 config.analysis.count_compiler_directives =
3334 form.count_compiler_directives.as_deref() != Some("disabled");
3335 if let Some(cov) = &form.coverage_file {
3336 let trimmed = cov.trim();
3337 if !trimmed.is_empty() {
3338 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3339 }
3340 }
3341}
3342
3343fn spawn_pdf_background(
3347 pending_pdf: PendingPdf,
3348 run_id: String,
3349 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3350) {
3351 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3352 tokio::spawn(async move {
3353 let result = tokio::task::spawn_blocking(move || {
3354 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3355 if cleanup_src {
3356 let _ = fs::remove_file(&pdf_src);
3357 }
3358 r
3359 })
3360 .await;
3361 let failed = match result {
3362 Ok(Ok(())) => false,
3363 Ok(Err(err)) => {
3364 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3365 true
3366 }
3367 Err(err) => {
3368 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3369 true
3370 }
3371 };
3372 if failed {
3373 let mut map = artifacts.lock().await;
3374 if let Some(entry) = map.get_mut(&run_id) {
3375 entry.pdf_path = None;
3376 }
3377 }
3378 });
3379 }
3380}
3381
3382fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3384 cmp.file_deltas
3385 .iter()
3386 .map(|f| match f.status {
3387 FileChangeStatus::Added => f.current_code,
3388 FileChangeStatus::Modified => f.code_delta.max(0),
3389 _ => 0,
3390 })
3391 .sum()
3392}
3393
3394fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3396 cmp.file_deltas
3397 .iter()
3398 .map(|f| match f.status {
3399 FileChangeStatus::Removed => f.baseline_code,
3400 FileChangeStatus::Modified => (-f.code_delta).max(0),
3401 _ => 0,
3402 })
3403 .sum()
3404}
3405
3406fn build_submodule_row(
3408 s: &sloc_core::SubmoduleSummary,
3409 run: &AnalysisRun,
3410 run_id: &str,
3411 run_dir: &Path,
3412 generate_html: bool,
3413) -> SubmoduleRow {
3414 let safe = sanitize_project_label(&s.name);
3415 let artifact_key = format!("sub_{safe}");
3416 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
3417 let parent_path = run
3418 .input_roots
3419 .first()
3420 .map_or("", std::string::String::as_str);
3421 let sub_run = build_sub_run(run, s, parent_path);
3422 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3423 let path = run_dir.join(format!("{artifact_key}.html"));
3424 if fs::write(&path, sub_html.as_bytes()).is_ok() {
3425 Some(format!("/runs/{artifact_key}/{run_id}"))
3426 } else {
3427 None
3428 }
3429 })
3430 } else {
3431 None
3432 };
3433 SubmoduleRow {
3434 name: s.name.clone(),
3435 relative_path: s.relative_path.clone(),
3436 files_analyzed: s.files_analyzed,
3437 code_lines: s.code_lines,
3438 comment_lines: s.comment_lines,
3439 blank_lines: s.blank_lines,
3440 total_physical_lines: s.total_physical_lines,
3441 html_url,
3442 }
3443}
3444
3445#[allow(clippy::similar_names)]
3448#[allow(clippy::significant_drop_tightening)] async fn analyze_handler(
3450 State(state): State<AppState>,
3451 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3452 Form(form): Form<AnalyzeForm>,
3453) -> impl IntoResponse {
3454 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3455 let template = ErrorTemplate {
3456 message: format!(
3457 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
3458 Please wait a moment and try again."
3459 ),
3460 last_report_url: None,
3461 last_report_label: None,
3462 csp_nonce: csp_nonce.clone(),
3463 version: env!("CARGO_PKG_VERSION"),
3464 };
3465 return (
3466 StatusCode::SERVICE_UNAVAILABLE,
3467 Html(
3468 template
3469 .render()
3470 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3471 ),
3472 )
3473 .into_response();
3474 };
3475
3476 let mut config = state.base_config.clone();
3477
3478 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3479 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3480 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3481
3482 if !is_git_mode {
3483 let resolved_path = resolve_input_path(&form.path);
3484 if state.server_mode
3485 && !is_upload_tmp_path(&resolved_path)
3486 && !is_sample_path(&resolved_path)
3487 {
3488 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3489 return resp;
3490 }
3491 }
3492 config.discovery.root_paths = vec![resolved_path];
3493 }
3494
3495 apply_form_to_config(&mut config, &form);
3496 apply_output_dir_exclusions(
3497 &mut config,
3498 &form.path,
3499 form.output_dir.as_deref().unwrap_or(""),
3500 );
3501
3502 let wait_id = uuid::Uuid::new_v4().to_string();
3504 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3505
3506 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3508 let task_cancel = Arc::clone(&cancel_token);
3509
3510 {
3513 let mut runs = state.async_runs.lock().await;
3514 runs.insert(
3515 wait_id.clone(),
3516 AsyncRunState::Running {
3517 started_at: std::time::Instant::now(),
3518 cancel_token,
3519 },
3520 );
3521 }
3522
3523 let task = AnalysisTask {
3524 sem_permit,
3525 state: state.clone(),
3526 wait_id: wait_id.clone(),
3527 config,
3528 cancel: task_cancel,
3529 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
3530 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
3531 generate_html: form.generate_html.is_some(),
3532 generate_pdf: form.generate_pdf.is_some(),
3533 project_path: form.path.clone(),
3534 output_dir: if state.server_mode {
3538 None
3539 } else {
3540 form.output_dir.clone()
3541 },
3542 clones_dir: state.git_clones_dir.clone(),
3543 };
3544
3545 tokio::spawn(run_analysis_task(task));
3546
3547 let template = ScanWaitTemplate {
3548 version: env!("CARGO_PKG_VERSION"),
3549 wait_id_json,
3550 project_path: form.path.clone(),
3551 csp_nonce,
3552 };
3553 let html = template
3554 .render()
3555 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3556 let mut response = Html(html).into_response();
3557 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3558 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3559 response.headers_mut().insert(name, val);
3560 }
3561 }
3562 response
3563}
3564
3565struct AnalysisTask {
3566 sem_permit: tokio::sync::OwnedSemaphorePermit,
3567 state: AppState,
3568 wait_id: String,
3569 config: AppConfig,
3570 cancel: Arc<std::sync::atomic::AtomicBool>,
3571 git_repo: Option<String>,
3572 git_ref: Option<String>,
3573 generate_html: bool,
3574 generate_pdf: bool,
3575 project_path: String,
3576 output_dir: Option<String>,
3577 clones_dir: PathBuf,
3578}
3579
3580#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
3582 let _permit = task.sem_permit;
3583
3584 let cancel_sb = Arc::clone(&task.cancel);
3585 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
3586 let clones_dir_sb = task.clones_dir;
3587 let upload_staging_root = task
3589 .config
3590 .discovery
3591 .root_paths
3592 .first()
3593 .filter(|p| is_upload_tmp_path(p))
3594 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
3595 .map(PathBuf::from);
3596 let config_sb = task.config;
3597 let analysis_result = tokio::task::spawn_blocking(move || {
3598 run_analysis_blocking(config_sb, git_repo_sb, git_ref_sb, clones_dir_sb, cancel_sb)
3599 })
3600 .await
3601 .map_err(|err| anyhow::anyhow!(err.to_string()))
3602 .and_then(|result| result);
3603
3604 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
3606 let mut runs = task.state.async_runs.lock().await;
3607 if matches!(
3609 runs.get(&task.wait_id),
3610 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3611 ) {
3612 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3613 }
3614 drop(runs);
3615 return;
3616 }
3617
3618 let (run, report_html) = match analysis_result {
3619 Ok(v) => v,
3620 Err(err) => {
3621 if err.to_string().contains("analysis cancelled") {
3623 let mut runs = task.state.async_runs.lock().await;
3624 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3625 drop(runs);
3626 return;
3627 }
3628 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3629 let mut runs = task.state.async_runs.lock().await;
3630 runs.insert(
3631 task.wait_id.clone(),
3632 AsyncRunState::Failed {
3633 message: "Analysis failed. Check that the path exists and is readable."
3634 .to_string(),
3635 },
3636 );
3637 drop(runs);
3638 return;
3639 }
3640 };
3641
3642 let run_id = run.tool.run_id.clone();
3643 tracing::info!(event = "scan_complete", run_id = %run_id,
3644 path = %task.project_path, files = run.summary_totals.files_analyzed,
3645 "Analysis finished");
3646
3647 let prev_entry: Option<RegistryEntry> = {
3648 let reg = task.state.registry.lock().await;
3649 reg.entries_for_roots(&run.input_roots)
3650 .into_iter()
3651 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3652 .cloned()
3653 };
3654
3655 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3656 prev.json_path
3657 .as_ref()
3658 .and_then(|p| read_json(p).ok())
3659 .map(|prev_run| compute_delta(&prev_run, &run))
3660 });
3661 let prev_scan_count: usize = {
3662 let reg = task.state.registry.lock().await;
3663 reg.entries_for_roots(&run.input_roots)
3664 .iter()
3665 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3666 .count()
3667 };
3668
3669 let output_root = resolve_output_root(task.output_dir.as_deref());
3670 let project_label = derive_project_label(
3671 task.git_repo.as_deref(),
3672 task.git_ref.as_deref(),
3673 &task.project_path,
3674 );
3675 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3676 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
3677
3678 let result_context = RunResultContext {
3679 prev_entry: prev_entry.clone(),
3680 prev_scan_count,
3681 project_path: task.project_path.clone(),
3682 };
3683
3684 let artifact_result = persist_run_artifacts(
3685 &run,
3686 &report_html,
3687 &run_dir,
3688 true,
3689 task.generate_html,
3690 task.generate_pdf,
3691 &run.effective_configuration.reporting.report_title,
3692 &file_stem,
3693 result_context,
3694 );
3695
3696 let (artifacts, pending_pdf) = match artifact_result {
3697 Ok(v) => v,
3698 Err(err) => {
3699 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3700 let mut runs = task.state.async_runs.lock().await;
3701 runs.insert(
3702 task.wait_id.clone(),
3703 AsyncRunState::Failed {
3704 message: "Failed to save report artifacts. Check available disk space."
3705 .to_string(),
3706 },
3707 );
3708 drop(runs);
3709 return;
3710 }
3711 };
3712
3713 {
3714 let mut map = task.state.artifacts.lock().await;
3715 map.insert(run_id.clone(), artifacts.clone());
3716 }
3717
3718 {
3719 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3720 let mut reg = task.state.registry.lock().await;
3721 reg.add_entry(entry);
3722 let _ = reg.save(&task.state.registry_path);
3723 }
3724
3725 if let Some(ref cfg_path) = artifacts.scan_config_path {
3726 save_scan_config_json(
3727 cfg_path,
3728 &run,
3729 &task.project_path,
3730 task.output_dir.as_deref(),
3731 task.generate_html,
3732 task.generate_pdf,
3733 );
3734 }
3735
3736 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
3737
3738 let mut runs = task.state.async_runs.lock().await;
3740 runs.insert(
3741 task.wait_id.clone(),
3742 AsyncRunState::Complete {
3743 run_id: run_id.clone(),
3744 },
3745 );
3746 drop(runs);
3747
3748 if let Some(staging) = upload_staging_root {
3751 let _ = tokio::fs::remove_dir_all(staging).await;
3752 }
3753
3754 let _ = scan_delta;
3755}
3756
3757fn save_scan_config_json(
3758 cfg_path: &std::path::Path,
3759 run: &sloc_core::AnalysisRun,
3760 project_path: &str,
3761 output_dir: Option<&str>,
3762 generate_html: bool,
3763 generate_pdf: bool,
3764) {
3765 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3766 .ok()
3767 .and_then(|v| v.as_str().map(String::from))
3768 .unwrap_or_else(|| "code_only".to_string());
3769 let behavior_str =
3770 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3771 .ok()
3772 .and_then(|v| v.as_str().map(String::from))
3773 .unwrap_or_else(|| "skip".to_string());
3774 let scan_cfg = ScanConfig {
3775 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3776 path: project_path.to_string(),
3777 include_globs: run
3778 .effective_configuration
3779 .discovery
3780 .include_globs
3781 .join("\n"),
3782 exclude_globs: run
3783 .effective_configuration
3784 .discovery
3785 .exclude_globs
3786 .join("\n"),
3787 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3788 mixed_line_policy: policy_str,
3789 python_docstrings_as_comments: run
3790 .effective_configuration
3791 .analysis
3792 .python_docstrings_as_comments,
3793 generated_file_detection: run
3794 .effective_configuration
3795 .analysis
3796 .generated_file_detection,
3797 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
3798 vendor_directory_detection: run
3799 .effective_configuration
3800 .analysis
3801 .vendor_directory_detection,
3802 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3803 binary_file_behavior: behavior_str,
3804 output_dir: output_dir.unwrap_or("").to_string(),
3805 report_title: run.effective_configuration.reporting.report_title.clone(),
3806 generate_html,
3807 generate_pdf,
3808 };
3809 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3810 let _ = std::fs::write(cfg_path, json);
3811 }
3812}
3813
3814#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
3816 mut config: AppConfig,
3817 git_repo: Option<String>,
3818 git_ref: Option<String>,
3819 clones_dir: PathBuf,
3820 cancel: Arc<std::sync::atomic::AtomicBool>,
3821) -> Result<(sloc_core::AnalysisRun, String)> {
3822 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3823 let dest = git_clone_dest(&repo, &clones_dir);
3824 sloc_git::clone_or_fetch(&repo, &dest)?;
3825 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3826 sloc_git::create_worktree(&dest, &refname, &wt)?;
3827 config.discovery.root_paths = vec![wt.clone()];
3828 let run = analyze(&config, "serve", Some(&cancel));
3829 let _ = sloc_git::destroy_worktree(&dest, &wt);
3830 let mut run = run?;
3831 if run.git_branch.is_none() {
3832 run.git_branch = Some(refname);
3833 }
3834 let html = render_html(&run)?;
3835 return Ok((run, html));
3836 }
3837 let run = analyze(&config, "serve", Some(&cancel))?;
3838 let html = render_html(&run)?;
3839 Ok((run, html))
3840}
3841
3842fn derive_project_label(
3843 git_repo: Option<&str>,
3844 git_ref: Option<&str>,
3845 fallback_path: &str,
3846) -> String {
3847 match (
3848 git_repo.filter(|s| !s.is_empty()),
3849 git_ref.filter(|s| !s.is_empty()),
3850 ) {
3851 (Some(repo), Some(refname)) => {
3852 let repo_name = repo
3853 .trim_end_matches('/')
3854 .trim_end_matches(".git")
3855 .rsplit('/')
3856 .next()
3857 .unwrap_or("repo");
3858 sanitize_project_label(&format!("{repo_name}_{refname}"))
3859 }
3860 _ => sanitize_project_label(fallback_path),
3861 }
3862}
3863
3864fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3865 let commit = commit_short.unwrap_or("").trim();
3866 if commit.is_empty() {
3867 project_label.to_string()
3868 } else {
3869 format!("{project_label}_{commit}")
3870 }
3871}
3872
3873#[derive(Serialize)]
3876#[serde(tag = "state", rename_all = "snake_case")]
3877enum AsyncRunStatusResponse {
3878 Running { elapsed_secs: u64 },
3879 Complete { run_id: String },
3880 Failed { message: String },
3881 Cancelled,
3882}
3883
3884async fn async_run_status_handler(
3885 State(state): State<AppState>,
3886 AxumPath(wait_id): AxumPath<String>,
3887) -> Response {
3888 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3890 return error::bad_request("invalid wait_id");
3891 }
3892 let run_state = {
3893 let runs = state.async_runs.lock().await;
3894 runs.get(&wait_id).cloned()
3895 };
3896 match run_state {
3897 None => error::not_found("run not found"),
3898 Some(AsyncRunState::Running { started_at, .. }) => {
3899 if started_at.elapsed() > std::time::Duration::from_hours(2) {
3901 let mut runs = state.async_runs.lock().await;
3902 runs.insert(
3903 wait_id,
3904 AsyncRunState::Failed {
3905 message: "Analysis timed out after 2 hours.".to_string(),
3906 },
3907 );
3908 drop(runs);
3909 return Json(AsyncRunStatusResponse::Failed {
3910 message: "Analysis timed out after 2 hours.".to_string(),
3911 })
3912 .into_response();
3913 }
3914 Json(AsyncRunStatusResponse::Running {
3915 elapsed_secs: started_at.elapsed().as_secs(),
3916 })
3917 .into_response()
3918 }
3919 Some(AsyncRunState::Complete { run_id }) => {
3920 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3921 }
3922 Some(AsyncRunState::Failed { message }) => {
3923 Json(AsyncRunStatusResponse::Failed { message }).into_response()
3924 }
3925 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3926 }
3927}
3928
3929async fn cancel_run_handler(
3930 State(state): State<AppState>,
3931 AxumPath(wait_id): AxumPath<String>,
3932) -> Response {
3933 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3934 return error::bad_request("invalid wait_id");
3935 }
3936 let mut runs = state.async_runs.lock().await;
3937 let resp = match runs.get(&wait_id) {
3938 Some(AsyncRunState::Running { cancel_token, .. }) => {
3939 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3940 runs.insert(wait_id, AsyncRunState::Cancelled);
3941 StatusCode::OK.into_response()
3942 }
3943 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3944 _ => error::not_found("run not found"),
3945 };
3946 drop(runs);
3947 resp
3948}
3949
3950async fn async_run_result_handler(
3951 State(state): State<AppState>,
3952 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3953 AxumPath(run_id): AxumPath<String>,
3954) -> Response {
3955 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3956 return StatusCode::BAD_REQUEST.into_response();
3957 }
3958
3959 let artifacts = {
3960 let map = state.artifacts.lock().await;
3961 map.get(&run_id).cloned()
3962 };
3963 let artifacts = if let Some(a) = artifacts {
3964 a
3965 } else {
3966 let reg = state.registry.lock().await;
3967 if let Some(entry) = reg.find_by_run_id(&run_id) {
3968 recover_artifacts_from_registry(entry)
3969 } else {
3970 let html = ErrorTemplate {
3971 message: format!(
3972 "Report not found. Run ID {} is not in the scan history.",
3973 &run_id[..run_id.len().min(8)]
3974 ),
3975 last_report_url: Some("/view-reports".to_string()),
3976 last_report_label: Some("View Reports".to_string()),
3977 csp_nonce: csp_nonce.clone(),
3978 version: env!("CARGO_PKG_VERSION"),
3979 }
3980 .render()
3981 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3982 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3983 }
3984 };
3985
3986 let json_path = if let Some(p) = &artifacts.json_path {
3987 p.clone()
3988 } else {
3989 let html = ErrorTemplate {
3990 message: "JSON result was not saved for this run.".to_string(),
3991 last_report_url: Some("/view-reports".to_string()),
3992 last_report_label: Some("View Reports".to_string()),
3993 csp_nonce: csp_nonce.clone(),
3994 version: env!("CARGO_PKG_VERSION"),
3995 }
3996 .render()
3997 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3998 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3999 };
4000
4001 let Ok(run) = read_json(&json_path) else {
4002 let folder_hint = json_path
4003 .parent()
4004 .map(|p| p.display().to_string())
4005 .unwrap_or_default();
4006 let redirect_url = format!("/runs/result/{run_id}");
4007 return missing_scan_relocate_response(
4008 &format!(
4009 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4010 deleted. Browse to the folder containing your scan output to reconnect it.",
4011 json_path.display()
4012 ),
4013 &run_id,
4014 &folder_hint,
4015 &redirect_url,
4016 state.server_mode,
4017 &csp_nonce,
4018 );
4019 };
4020
4021 let confluence_configured = {
4022 let store = state.confluence.lock().await;
4023 store.is_configured()
4024 };
4025
4026 render_result_page(
4027 &run,
4028 &artifacts,
4029 &run_id,
4030 &csp_nonce,
4031 confluence_configured,
4032 state.server_mode,
4033 )
4034}
4035
4036#[allow(clippy::too_many_lines)]
4037#[allow(clippy::similar_names)] fn render_result_page(
4039 run: &AnalysisRun,
4040 artifacts: &RunArtifacts,
4041 run_id: &str,
4042 csp_nonce: &str,
4043 confluence_configured: bool,
4044 server_mode: bool,
4045) -> Response {
4046 let ctx = &artifacts.result_context;
4047 let prev_entry = &ctx.prev_entry;
4048 let prev_scan_count = ctx.prev_scan_count;
4049 let project_path = &ctx.project_path;
4050
4051 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4052 prev.json_path
4053 .as_ref()
4054 .and_then(|p| read_json(p).ok())
4055 .map(|prev_run| compute_delta(&prev_run, run))
4056 });
4057
4058 let files_analyzed = run.per_file_records.len() as u64;
4059 let files_skipped = run.skipped_file_records.len() as u64;
4060 let physical_lines = run
4061 .totals_by_language
4062 .iter()
4063 .map(|r| r.total_physical_lines)
4064 .sum::<u64>();
4065 let code_lines = run
4066 .totals_by_language
4067 .iter()
4068 .map(|r| r.code_lines)
4069 .sum::<u64>();
4070 let comment_lines = run
4071 .totals_by_language
4072 .iter()
4073 .map(|r| r.comment_lines)
4074 .sum::<u64>();
4075 let blank_lines = run
4076 .totals_by_language
4077 .iter()
4078 .map(|r| r.blank_lines)
4079 .sum::<u64>();
4080 let mixed_lines = run
4081 .totals_by_language
4082 .iter()
4083 .map(|r| r.mixed_lines_separate)
4084 .sum::<u64>();
4085 let functions = run
4086 .totals_by_language
4087 .iter()
4088 .map(|r| r.functions)
4089 .sum::<u64>();
4090 let classes = run
4091 .totals_by_language
4092 .iter()
4093 .map(|r| r.classes)
4094 .sum::<u64>();
4095 let variables = run
4096 .totals_by_language
4097 .iter()
4098 .map(|r| r.variables)
4099 .sum::<u64>();
4100 let imports = run
4101 .totals_by_language
4102 .iter()
4103 .map(|r| r.imports)
4104 .sum::<u64>();
4105
4106 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4107 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4108 let prev_fs = prev_sum.map(|s| s.files_skipped);
4109 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4110 let prev_cl = prev_sum.map(|s| s.code_lines);
4111 let prev_cml = prev_sum.map(|s| s.comment_lines);
4112 let prev_bl = prev_sum.map(|s| s.blank_lines);
4113 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4114 let prev_fa_str = fmt_prev(prev_fa);
4115 let prev_fs_str = fmt_prev(prev_fs);
4116 let prev_pl_str = fmt_prev(prev_pl);
4117 let prev_cl_str = fmt_prev(prev_cl);
4118 let prev_cml_str = fmt_prev(prev_cml);
4119 let prev_bl_str = fmt_prev(prev_bl);
4120 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4121 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4122 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4123 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4124 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4125 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4126 let delta_fa_class = delta_fa_class.to_string();
4127 let delta_fs_class = delta_fs_class.to_string();
4128 let delta_pl_class = delta_pl_class.to_string();
4129 let delta_cl_class = delta_cl_class.to_string();
4130 let delta_cml_class = delta_cml_class.to_string();
4131 let delta_bl_class = delta_bl_class.to_string();
4132
4133 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4134 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4135 let (delta_lines_net_str, delta_lines_net_class) =
4136 match (delta_lines_added, delta_lines_removed) {
4137 (Some(a), Some(r)) => {
4138 let net = a - r;
4139 (fmt_delta(net), delta_class(net).to_string())
4140 }
4141 _ => ("—".to_string(), "na".to_string()),
4142 };
4143
4144 let run_dir = artifacts.output_dir.clone();
4145 let git_branch = run.git_branch.clone();
4146 let git_commit = run.git_commit_short.clone();
4147 let git_commit_long = run.git_commit_long.clone();
4148 let git_author = run.git_commit_author.clone();
4149 let scan_performed_by = format!(
4150 "{} / {}",
4151 run.environment.initiator_username, run.environment.initiator_hostname
4152 );
4153 let scan_time_display = fmt_la_time(run.tool.timestamp_utc);
4154 let os_display = format!(
4155 "{} / {}",
4156 run.environment.operating_system, run.environment.architecture
4157 );
4158 let test_count = run.summary_totals.test_count;
4159
4160 let template = ResultTemplate {
4161 version: env!("CARGO_PKG_VERSION"),
4162 report_title: run.effective_configuration.reporting.report_title.clone(),
4163 project_path: project_path.clone(),
4164 output_dir: display_path(&artifacts.output_dir),
4165 run_id: run_id.to_owned(),
4166 run_id_short: run_id
4167 .split('-')
4168 .next_back()
4169 .unwrap_or(run_id)
4170 .chars()
4171 .take(7)
4172 .collect(),
4173 files_analyzed,
4174 files_skipped,
4175 physical_lines,
4176 code_lines,
4177 comment_lines,
4178 blank_lines,
4179 mixed_lines,
4180 functions,
4181 classes,
4182 variables,
4183 imports,
4184 html_url: artifacts
4185 .html_path
4186 .as_ref()
4187 .map(|_| format!("/runs/html/{run_id}")),
4188 pdf_url: artifacts
4189 .pdf_path
4190 .as_ref()
4191 .map(|_| format!("/runs/pdf/{run_id}")),
4192 json_url: artifacts
4193 .json_path
4194 .as_ref()
4195 .map(|_| format!("/runs/json/{run_id}")),
4196 html_download_url: artifacts
4197 .html_path
4198 .as_ref()
4199 .map(|_| format!("/runs/html/{run_id}?download=1")),
4200 pdf_download_url: artifacts
4201 .pdf_path
4202 .as_ref()
4203 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4204 json_download_url: artifacts
4205 .json_path
4206 .as_ref()
4207 .map(|_| format!("/runs/json/{run_id}?download=1")),
4208 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4209 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4210 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4211 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4212 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4213 prev_fa_str,
4214 prev_fs_str,
4215 prev_pl_str,
4216 prev_cl_str,
4217 prev_cml_str,
4218 prev_bl_str,
4219 delta_fa_str,
4220 delta_fa_class,
4221 delta_fs_str,
4222 delta_fs_class,
4223 delta_pl_str,
4224 delta_pl_class,
4225 delta_cl_str,
4226 delta_cl_class,
4227 delta_cml_str,
4228 delta_cml_class,
4229 delta_bl_str,
4230 delta_bl_class,
4231 delta_lines_added,
4232 delta_lines_removed,
4233 delta_lines_net_str,
4234 delta_lines_net_class,
4235 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4236 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4237 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4238 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4239 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4240 d.file_deltas
4241 .iter()
4242 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4243 .map(|f| {
4244 #[allow(clippy::cast_sign_loss)]
4245 let n = f.current_code as u64;
4246 n
4247 })
4248 .sum()
4249 }),
4250 git_branch,
4251 git_commit,
4252 git_commit_long,
4253 git_author,
4254 scan_performed_by,
4255 scan_time_display,
4256 os_display,
4257 test_count,
4258 current_scan_number: prev_scan_count + 1,
4259 prev_scan_count,
4260 submodule_rows: run
4261 .submodule_summaries
4262 .iter()
4263 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4264 .collect(),
4265 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4266 scan_config_url: format!("/runs/scan-config/{run_id}"),
4267 lang_chart_json: {
4268 let entries: Vec<String> = run
4269 .totals_by_language
4270 .iter()
4271 .take(12)
4272 .map(|l| {
4273 let name = l
4274 .language
4275 .display_name()
4276 .replace('\\', "\\\\")
4277 .replace('"', "\\\"");
4278 format!(
4279 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4280 name,
4281 l.code_lines,
4282 l.comment_lines,
4283 l.blank_lines,
4284 l.functions,
4285 l.classes,
4286 l.variables,
4287 l.imports,
4288 l.files,
4289 )
4290 })
4291 .collect();
4292 format!("[{}]", entries.join(","))
4293 },
4294 scatter_chart_json: {
4295 let entries: Vec<String> = run
4296 .totals_by_language
4297 .iter()
4298 .map(|l| {
4299 let name = l
4300 .language
4301 .display_name()
4302 .replace('\\', "\\\\")
4303 .replace('"', "\\\"");
4304 format!(
4305 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4306 name, l.files, l.code_lines, l.total_physical_lines,
4307 )
4308 })
4309 .collect();
4310 format!("[{}]", entries.join(","))
4311 },
4312 semantic_chart_json: {
4313 let entries: Vec<String> = run
4314 .totals_by_language
4315 .iter()
4316 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4317 .map(|l| {
4318 let name = l
4319 .language
4320 .display_name()
4321 .replace('\\', "\\\\")
4322 .replace('"', "\\\"");
4323 format!(
4324 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4325 name, l.functions, l.classes, l.variables, l.imports,
4326 )
4327 })
4328 .collect();
4329 format!("[{}]", entries.join(","))
4330 },
4331 submodule_chart_json: {
4332 let entries: Vec<String> = run
4333 .submodule_summaries
4334 .iter()
4335 .map(|s| {
4336 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4337 format!(
4338 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4339 name,
4340 s.code_lines,
4341 s.comment_lines,
4342 s.blank_lines,
4343 s.total_physical_lines,
4344 s.files_analyzed,
4345 )
4346 })
4347 .collect();
4348 format!("[{}]", entries.join(","))
4349 },
4350 has_submodule_data: !run.submodule_summaries.is_empty(),
4351 has_semantic_data: run
4352 .totals_by_language
4353 .iter()
4354 .any(|l| l.functions > 0 || l.classes > 0),
4355 csp_nonce: csp_nonce.to_owned(),
4356 confluence_configured,
4357 server_mode,
4358 report_header_footer: run
4359 .effective_configuration
4360 .reporting
4361 .report_header_footer
4362 .clone(),
4363 };
4364
4365 Html(
4366 template
4367 .render()
4368 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4369 )
4370 .into_response()
4371}
4372
4373fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4374 let slug: String = report_title
4375 .chars()
4376 .map(|c| {
4377 if c.is_alphanumeric() || c == '-' {
4378 c.to_ascii_lowercase()
4379 } else {
4380 '_'
4381 }
4382 })
4383 .collect::<String>()
4384 .split('_')
4385 .filter(|s| !s.is_empty())
4386 .collect::<Vec<_>>()
4387 .join("_");
4388
4389 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4390
4391 if slug.is_empty() {
4392 format!("report_{short_id}.pdf")
4393 } else {
4394 format!("{slug}_{short_id}.pdf")
4395 }
4396}
4397
4398#[derive(Serialize)]
4399struct PdfStatusResponse {
4400 ready: bool,
4401}
4402
4403async fn pdf_status_handler(
4406 State(state): State<AppState>,
4407 AxumPath(run_id): AxumPath<String>,
4408) -> Response {
4409 let pdf_path = {
4410 let registry = state.artifacts.lock().await;
4411 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4412 };
4413 let pdf_path = if pdf_path.is_some() {
4414 pdf_path
4415 } else {
4416 let reg = state.registry.lock().await;
4417 reg.find_by_run_id(&run_id)
4418 .map(recover_artifacts_from_registry)
4419 .and_then(|a| a.pdf_path)
4420 };
4421 let ready = pdf_path.is_some_and(|p| p.exists());
4422 Json(PdfStatusResponse { ready }).into_response()
4423}
4424
4425async fn download_bundle_handler(
4431 State(state): State<AppState>,
4432 AxumPath(run_id): AxumPath<String>,
4433) -> Response {
4434 let output_dir = {
4436 let cache = state.artifacts.lock().await;
4437 cache.get(&run_id).map(|a| a.output_dir.clone())
4438 };
4439 let output_dir = if let Some(d) = output_dir {
4440 d
4441 } else {
4442 let reg = state.registry.lock().await;
4443 match reg.find_by_run_id(&run_id) {
4444 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4445 None => {
4446 return (
4447 StatusCode::NOT_FOUND,
4448 Json(serde_json::json!({"error": "Run not found"})),
4449 )
4450 .into_response();
4451 }
4452 }
4453 };
4454
4455 if !output_dir.exists() {
4456 return (
4457 StatusCode::NOT_FOUND,
4458 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4459 )
4460 .into_response();
4461 }
4462
4463 let run_id_clone = run_id.clone();
4465 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4466 use flate2::{write::GzEncoder, Compression};
4467 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4468 {
4469 let mut tar = tar::Builder::new(&mut enc);
4470 tar.follow_symlinks(false);
4471 if let Ok(entries) = std::fs::read_dir(&output_dir) {
4474 for entry in entries.filter_map(Result::ok) {
4475 let p = entry.path();
4476 if p.is_file() {
4477 let name = p.file_name().unwrap_or_default().to_string_lossy();
4478 let archive_path = format!("{run_id_clone}/{name}");
4479 tar.append_path_with_name(&p, &archive_path)?;
4480 }
4481 }
4482 }
4483 tar.finish()?;
4484 }
4485 Ok(enc.finish()?)
4486 })
4487 .await;
4488
4489 match archive_result {
4490 Ok(Ok(bytes)) => {
4491 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4492 axum::response::Response::builder()
4493 .status(StatusCode::OK)
4494 .header("Content-Type", "application/gzip")
4495 .header(
4496 "Content-Disposition",
4497 format!("attachment; filename=\"{filename}\""),
4498 )
4499 .header("Content-Length", bytes.len().to_string())
4500 .body(axum::body::Body::from(bytes))
4501 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4502 }
4503 Ok(Err(e)) => (
4504 StatusCode::INTERNAL_SERVER_ERROR,
4505 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4506 )
4507 .into_response(),
4508 Err(e) => (
4509 StatusCode::INTERNAL_SERVER_ERROR,
4510 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4511 )
4512 .into_response(),
4513 }
4514}
4515
4516async fn delete_run_handler(
4521 State(state): State<AppState>,
4522 AxumPath(run_id): AxumPath<String>,
4523) -> Response {
4524 let output_dir = {
4526 let mut cache = state.artifacts.lock().await;
4527 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4528 cache.remove(&run_id);
4529 dir
4530 };
4531 let output_dir = if let Some(d) = output_dir {
4532 d
4533 } else {
4534 let reg = state.registry.lock().await;
4535 reg.find_by_run_id(&run_id)
4536 .map(|e| recover_artifacts_from_registry(e).output_dir)
4537 .unwrap_or_default()
4538 };
4539
4540 {
4542 let mut reg = state.registry.lock().await;
4543 reg.entries.retain(|e| e.run_id != run_id);
4544 let _ = reg.save(&state.registry_path);
4545 }
4546
4547 if output_dir.exists() {
4549 if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4550 return (
4551 StatusCode::INTERNAL_SERVER_ERROR,
4552 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4553 )
4554 .into_response();
4555 }
4556 }
4557
4558 StatusCode::NO_CONTENT.into_response()
4559}
4560
4561async fn cleanup_runs_handler(
4566 State(state): State<AppState>,
4567 Json(body): Json<serde_json::Value>,
4568) -> Response {
4569 let days = body
4570 .get("older_than_days")
4571 .and_then(serde_json::Value::as_u64)
4572 .unwrap_or(30)
4573 .max(1);
4574
4575 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4576
4577 let expired: Vec<(String, PathBuf)> = {
4579 let reg = state.registry.lock().await;
4580 reg.entries
4581 .iter()
4582 .filter(|e| e.timestamp_utc < cutoff)
4583 .map(|e| {
4584 let arts = recover_artifacts_from_registry(e);
4585 (e.run_id.clone(), arts.output_dir)
4586 })
4587 .collect()
4588 };
4589
4590 let mut deleted = 0usize;
4591 for (run_id, output_dir) in &expired {
4592 state.artifacts.lock().await.remove(run_id);
4594 if output_dir.exists() {
4596 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4597 eprintln!(
4598 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4599 output_dir.display()
4600 );
4601 continue;
4602 }
4603 }
4604 deleted += 1;
4605 }
4606
4607 let expired_ids: std::collections::HashSet<&str> =
4609 expired.iter().map(|(id, _)| id.as_str()).collect();
4610 {
4611 let mut reg = state.registry.lock().await;
4612 reg.entries
4613 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4614 let _ = reg.save(&state.registry_path);
4615 }
4616
4617 Json(serde_json::json!({ "deleted": deleted })).into_response()
4618}
4619
4620fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4625 let Some(start) = html.find("nonce=\"") else {
4627 return html
4631 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4632 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4633 };
4634 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
4636 return html.to_owned();
4637 };
4638 let old_nonce = &html[value_start..value_start + end_offset];
4639 html.replace(
4640 &format!("nonce=\"{old_nonce}\""),
4641 &format!("nonce=\"{new_nonce}\""),
4642 )
4643}
4644
4645fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4646 match fs::read_to_string(path) {
4647 Ok(raw) => {
4648 let content = patch_html_nonce(&raw, csp_nonce);
4650 if wants_download {
4651 (
4652 [
4653 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4654 (
4655 header::CONTENT_DISPOSITION,
4656 "attachment; filename=report.html",
4657 ),
4658 ],
4659 content,
4660 )
4661 .into_response()
4662 } else {
4663 Html(content).into_response()
4664 }
4665 }
4666 Err(err) => {
4667 let filename = path.file_name().map_or_else(
4668 || "report.html".to_string(),
4669 |n| n.to_string_lossy().into_owned(),
4670 );
4671 let msg = format!(
4672 "HTML report '{filename}' could not be read.\n\n\
4673 Error: {err}\n\n\
4674 If you moved or renamed the output folder, the stored path is now stale. \
4675 Use 'Open HTML folder' from the results page to browse the output directory."
4676 );
4677 let html = ErrorTemplate {
4678 message: msg,
4679 last_report_url: Some("/view-reports".to_string()),
4680 last_report_label: Some("View Reports".to_string()),
4681 csp_nonce: csp_nonce.to_owned(),
4682 version: env!("CARGO_PKG_VERSION"),
4683 }
4684 .render()
4685 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4686 (StatusCode::NOT_FOUND, Html(html)).into_response()
4687 }
4688 }
4689}
4690
4691fn serve_pdf_artifact(
4693 path: &Path,
4694 report_title: &str,
4695 run_id: &str,
4696 wants_download: bool,
4697 csp_nonce: &str,
4698) -> Response {
4699 match fs::read(path) {
4700 Ok(bytes) => {
4701 let filename = build_pdf_filename(report_title, run_id);
4702 let disposition = if wants_download {
4703 format!("attachment; filename=\"{filename}\"")
4704 } else {
4705 format!("inline; filename=\"{filename}\"")
4706 };
4707 (
4708 [
4709 (header::CONTENT_TYPE, "application/pdf".to_string()),
4710 (header::CONTENT_DISPOSITION, disposition),
4711 ],
4712 bytes,
4713 )
4714 .into_response()
4715 }
4716 Err(err) => {
4717 let filename = path.file_name().map_or_else(
4718 || "report.pdf".to_string(),
4719 |n| n.to_string_lossy().into_owned(),
4720 );
4721 let msg = format!(
4722 "PDF report '{filename}' could not be read.\n\n\
4723 Error: {err}\n\n\
4724 If you moved or renamed the output folder, the stored path is now stale. \
4725 Use 'Open PDF folder' from the results page to browse the output directory."
4726 );
4727 let html = ErrorTemplate {
4728 message: msg,
4729 last_report_url: Some("/view-reports".to_string()),
4730 last_report_label: Some("View Reports".to_string()),
4731 csp_nonce: csp_nonce.to_owned(),
4732 version: env!("CARGO_PKG_VERSION"),
4733 }
4734 .render()
4735 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4736 (StatusCode::NOT_FOUND, Html(html)).into_response()
4737 }
4738 }
4739}
4740
4741fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4743 match fs::read(path) {
4744 Ok(bytes) => {
4745 if wants_download {
4746 (
4747 [
4748 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4749 (
4750 header::CONTENT_DISPOSITION,
4751 "attachment; filename=result.json",
4752 ),
4753 ],
4754 bytes,
4755 )
4756 .into_response()
4757 } else {
4758 (
4759 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4760 bytes,
4761 )
4762 .into_response()
4763 }
4764 }
4765 Err(err) => {
4766 let filename = path.file_name().map_or_else(
4767 || "result.json".to_string(),
4768 |n| n.to_string_lossy().into_owned(),
4769 );
4770 let msg = format!(
4771 "JSON result '{filename}' could not be read.\n\n\
4772 Error: {err}\n\n\
4773 If you moved or renamed the output folder, the stored path is now stale. \
4774 Use 'Open JSON folder' from the results page to browse the output directory."
4775 );
4776 let html = ErrorTemplate {
4777 message: msg,
4778 last_report_url: Some("/view-reports".to_string()),
4779 last_report_label: Some("View Reports".to_string()),
4780 csp_nonce: csp_nonce.to_owned(),
4781 version: env!("CARGO_PKG_VERSION"),
4782 }
4783 .render()
4784 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4785 (StatusCode::NOT_FOUND, Html(html)).into_response()
4786 }
4787 }
4788}
4789
4790fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4792 let output_dir = entry
4793 .html_path
4794 .as_ref()
4795 .or(entry.json_path.as_ref())
4796 .or(entry.pdf_path.as_ref())
4797 .or(entry.csv_path.as_ref())
4798 .or(entry.xlsx_path.as_ref())
4799 .and_then(|p| p.parent().map(PathBuf::from))
4800 .unwrap_or_default();
4801 let pdf_path = entry.pdf_path.clone().or_else(|| {
4804 let candidate = output_dir.join("report.pdf");
4805 candidate.exists().then_some(candidate)
4806 });
4807 let csv_path = entry.csv_path.clone().or_else(|| {
4811 fs::read_dir(&output_dir).ok().and_then(|entries| {
4812 entries
4813 .filter_map(std::result::Result::ok)
4814 .find(|e| {
4815 let n = e.file_name();
4816 let n = n.to_string_lossy();
4817 n.starts_with("report_") && n.ends_with(".csv")
4818 })
4819 .map(|e| e.path())
4820 })
4821 });
4822 let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4823 fs::read_dir(&output_dir).ok().and_then(|entries| {
4824 entries
4825 .filter_map(std::result::Result::ok)
4826 .find(|e| {
4827 let n = e.file_name();
4828 let n = n.to_string_lossy();
4829 n.starts_with("report_") && n.ends_with(".xlsx")
4830 })
4831 .map(|e| e.path())
4832 })
4833 });
4834 RunArtifacts {
4835 output_dir: output_dir.clone(),
4836 html_path: entry.html_path.clone(),
4837 pdf_path,
4838 json_path: entry.json_path.clone(),
4839 csv_path,
4840 xlsx_path,
4841 scan_config_path: find_scan_config_in_dir(&output_dir),
4842 report_title: entry.project_label.clone(),
4843 result_context: RunResultContext::default(),
4844 }
4845}
4846
4847#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
4849 state: &AppState,
4850 run_id: &str,
4851 csp_nonce: &str,
4852) -> Result<RunArtifacts, Response> {
4853 let cached = state.artifacts.lock().await.get(run_id).cloned();
4854 if let Some(a) = cached {
4855 return Ok(a);
4856 }
4857 let reg = state.registry.lock().await;
4858 if let Some(entry) = reg.find_by_run_id(run_id) {
4859 return Ok(recover_artifacts_from_registry(entry));
4860 }
4861 drop(reg);
4862 let short_id = &run_id[..run_id.len().min(8)];
4863 let hint = if matches!(
4864 run_id,
4865 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4866 ) {
4867 format!(
4868 " The URL format appears to be reversed — \
4869 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4870 Use the View Reports page to navigate to your scan."
4871 )
4872 } else {
4873 " The report may have been deleted or the report directory moved. \
4874 Use View Reports to browse your scan history."
4875 .to_string()
4876 };
4877 let error_html = ErrorTemplate {
4878 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
4879 last_report_url: Some("/view-reports".to_string()),
4880 last_report_label: Some("View Reports".to_string()),
4881 csp_nonce: csp_nonce.to_owned(),
4882 version: env!("CARGO_PKG_VERSION"),
4883 }
4884 .render()
4885 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4886 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
4887}
4888
4889#[allow(clippy::too_many_lines)] async fn artifact_handler(
4891 State(state): State<AppState>,
4892 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4893 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4894 Query(query): Query<ArtifactQuery>,
4895) -> Response {
4896 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
4897 Ok(a) => a,
4898 Err(r) => return r,
4899 };
4900
4901 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4902
4903 match artifact.as_str() {
4904 "html" => {
4905 let Some(path) = artifact_set.html_path else {
4906 return StatusCode::NOT_FOUND.into_response();
4907 };
4908 serve_html_artifact(&path, wants_download, &csp_nonce)
4909 }
4910 "pdf" => {
4911 let Some(path) = artifact_set.pdf_path else {
4912 let msg = "PDF report was not generated for this run, or was not recorded in \
4913 the scan registry. Re-run the analysis with PDF output enabled."
4914 .to_string();
4915 let html = ErrorTemplate {
4916 message: msg,
4917 last_report_url: Some(format!("/runs/html/{run_id}")),
4918 last_report_label: Some("View HTML Report".to_string()),
4919 csp_nonce: csp_nonce.clone(),
4920 version: env!("CARGO_PKG_VERSION"),
4921 }
4922 .render()
4923 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4924 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4925 };
4926 if !path.exists() {
4929 let html = format!(
4930 "<!doctype html><html lang=\"en\"><head>\
4931 <meta charset=utf-8>\
4932 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4933 <meta http-equiv=\"refresh\" content=\"5\">\
4934 <title>OxideSLOC | Generating PDF\u{2026}</title>\
4935 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4936 <style nonce=\"{csp_nonce}\">\
4937 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4938 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4939 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4940 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4941 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4942 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4943 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4944 background:var(--bg);color:var(--text);}}\
4945 .top-nav{{position:sticky;top:0;z-index:30;\
4946 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4947 border-bottom:1px solid rgba(255,255,255,0.12);\
4948 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4949 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4950 min-height:56px;display:flex;align-items:center;gap:14px;}}\
4951 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4952 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4953 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4954 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4955 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4956 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4957 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4958 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4959 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4960 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4961 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4962 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4963 justify-content:center;min-height:38px;border-radius:999px;\
4964 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4965 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4966 .theme-toggle .icon-sun{{display:none;}}\
4967 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4968 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4969 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
4970 display:flex;align-items:center;justify-content:center;\
4971 min-height:calc(100vh - 56px);}}\
4972 .panel{{background:var(--surface);border:1px solid var(--line);\
4973 border-radius:var(--radius);box-shadow:var(--shadow);\
4974 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4975 .spin-ring{{width:56px;height:56px;border-radius:50%;\
4976 border:5px solid var(--line);border-top-color:var(--oxide-2);\
4977 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4978 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4979 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4980 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4981 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4982 min-height:42px;padding:0 20px;border-radius:14px;\
4983 border:1px solid var(--line-strong);text-decoration:none;\
4984 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4985 .back-link:hover{{background:var(--line);}}\
4986 </style></head>\
4987 <body>\
4988 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4989 <a class=\"brand\" href=\"/\">\
4990 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4991 <div class=\"brand-copy\">\
4992 <div class=\"brand-title\">OxideSLOC</div>\
4993 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4994 </div>\
4995 </a>\
4996 <div class=\"nav-right\">\
4997 <a class=\"nav-pill\" href=\"/\">Home</a>\
4998 <div class=\"nav-dropdown\">\
4999 <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>\
5000 <div class=\"nav-dropdown-menu\">\
5001 <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>\
5002 </div>\
5003 </div>\
5004 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5005 <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>\
5006 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5007 <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>\
5008 </button>\
5009 </div>\
5010 </div></div>\
5011 <div class=\"page\"><div class=\"panel\">\
5012 <div class=\"spin-ring\"></div>\
5013 <h1>Generating PDF\u{2026}</h1>\
5014 <p>The PDF is being rendered from the HTML report.<br>\
5015 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
5016 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5017 </div></div>\
5018 <script nonce=\"{csp_nonce}\">\
5019 (function(){{\
5020 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5021 if(s===\"dark\")b.classList.add(\"dark-theme\");\
5022 var t=document.getElementById(\"theme-toggle\");\
5023 if(t)t.addEventListener(\"click\",function(){{\
5024 var d=b.classList.toggle(\"dark-theme\");\
5025 localStorage.setItem(k,d?\"dark\":\"light\");\
5026 }});\
5027 }})();\
5028 </script>\
5029 </body></html>"
5030 );
5031 return Html(html).into_response();
5032 }
5033 serve_pdf_artifact(
5034 &path,
5035 &artifact_set.report_title,
5036 &run_id,
5037 wants_download,
5038 &csp_nonce,
5039 )
5040 }
5041 "json" => {
5042 let Some(path) = artifact_set.json_path else {
5043 let msg = "JSON result was not generated for this run, or was not recorded in \
5044 the scan registry. Re-run the analysis with JSON output enabled."
5045 .to_string();
5046 let html = ErrorTemplate {
5047 message: msg,
5048 last_report_url: Some("/view-reports".to_string()),
5049 last_report_label: Some("View Reports".to_string()),
5050 csp_nonce: csp_nonce.clone(),
5051 version: env!("CARGO_PKG_VERSION"),
5052 }
5053 .render()
5054 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5055 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5056 };
5057 serve_json_artifact(&path, wants_download, &csp_nonce)
5058 }
5059 "csv" => {
5060 let Some(path) = artifact_set.csv_path else {
5061 let msg = "CSV report was not generated for this run, or was not recorded in \
5062 the scan registry."
5063 .to_string();
5064 let html = ErrorTemplate {
5065 message: msg,
5066 last_report_url: Some(format!("/runs/html/{run_id}")),
5067 last_report_label: Some("View HTML Report".to_string()),
5068 csp_nonce: csp_nonce.clone(),
5069 version: env!("CARGO_PKG_VERSION"),
5070 }
5071 .render()
5072 .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5073 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5074 };
5075 fs::read(&path).map_or_else(
5076 |_| StatusCode::NOT_FOUND.into_response(),
5077 |bytes| {
5078 let filename = path.file_name().map_or_else(
5079 || "report.csv".to_string(),
5080 |n| n.to_string_lossy().into_owned(),
5081 );
5082 (
5083 [
5084 (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5085 (
5086 header::CONTENT_DISPOSITION,
5087 format!("attachment; filename=\"{filename}\""),
5088 ),
5089 ],
5090 bytes,
5091 )
5092 .into_response()
5093 },
5094 )
5095 }
5096 "xlsx" => {
5097 let Some(path) = artifact_set.xlsx_path else {
5098 let msg = "Excel report was not generated for this run, or was not recorded in \
5099 the scan registry."
5100 .to_string();
5101 let html = ErrorTemplate {
5102 message: msg,
5103 last_report_url: Some(format!("/runs/html/{run_id}")),
5104 last_report_label: Some("View HTML Report".to_string()),
5105 csp_nonce: csp_nonce.clone(),
5106 version: env!("CARGO_PKG_VERSION"),
5107 }
5108 .render()
5109 .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5110 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5111 };
5112 fs::read(&path).map_or_else(
5113 |_| StatusCode::NOT_FOUND.into_response(),
5114 |bytes| {
5115 let filename = path.file_name().map_or_else(
5116 || "report.xlsx".to_string(),
5117 |n| n.to_string_lossy().into_owned(),
5118 );
5119 (
5120 [
5121 (
5122 header::CONTENT_TYPE,
5123 "application/vnd.openxmlformats-officedocument\
5124 .spreadsheetml.sheet"
5125 .to_string(),
5126 ),
5127 (
5128 header::CONTENT_DISPOSITION,
5129 format!("attachment; filename=\"{filename}\""),
5130 ),
5131 ],
5132 bytes,
5133 )
5134 .into_response()
5135 },
5136 )
5137 }
5138 "scan-config" => {
5139 let path = artifact_set
5140 .scan_config_path
5141 .as_deref()
5142 .map(std::path::Path::to_path_buf)
5143 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5144 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5145 fs::read(&path).map_or_else(
5146 |_| StatusCode::NOT_FOUND.into_response(),
5147 |bytes| {
5148 (
5149 [
5150 (
5151 header::CONTENT_TYPE,
5152 "application/json; charset=utf-8".to_string(),
5153 ),
5154 (
5155 header::CONTENT_DISPOSITION,
5156 "attachment; filename=\"scan-config.json\"".to_string(),
5157 ),
5158 ],
5159 bytes,
5160 )
5161 .into_response()
5162 },
5163 )
5164 }
5165 _ if artifact.starts_with("sub_") => {
5166 if artifact.len() > 128
5167 || !artifact
5168 .chars()
5169 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5170 {
5171 return StatusCode::BAD_REQUEST.into_response();
5172 }
5173 let filename = format!("{artifact}.html");
5174 let path = artifact_set.output_dir.join(&filename);
5175 if !path.exists() {
5176 let html = ErrorTemplate {
5177 message: format!(
5178 "Sub-report '{artifact}' was not found in the run directory.\n\
5179 Re-run the analysis with 'Detect and separate git submodules' \
5180 and HTML output enabled."
5181 ),
5182 last_report_url: Some("/view-reports".to_string()),
5183 last_report_label: Some("View Reports".to_string()),
5184 csp_nonce: csp_nonce.clone(),
5185 version: env!("CARGO_PKG_VERSION"),
5186 }
5187 .render()
5188 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5189 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5190 }
5191 serve_html_artifact(&path, wants_download, &csp_nonce)
5192 }
5193 _ => StatusCode::NOT_FOUND.into_response(),
5194 }
5195}
5196
5197struct SubmoduleLinkRow {
5200 name: String,
5201 url: String,
5202}
5203
5204struct HistoryEntryRow {
5205 run_id: String,
5206 run_id_short: String,
5207 timestamp: String,
5208 timestamp_utc_ms: i64,
5209 project_label: String,
5210 project_path: String,
5211 files_analyzed: u64,
5212 files_skipped: u64,
5213 code_lines: u64,
5214 comment_lines: u64,
5215 blank_lines: u64,
5216 git_branch: String,
5217 git_commit: String,
5218 has_html: bool,
5219 has_json: bool,
5220 has_pdf: bool,
5221 submodule_links: Vec<SubmoduleLinkRow>,
5222 submodule_names_csv: String,
5224}
5225
5226fn nth_weekday_of_month(
5228 year: i32,
5229 month: u32,
5230 weekday: chrono::Weekday,
5231 n: u32,
5232) -> chrono::NaiveDate {
5233 use chrono::Datelike;
5234 let mut count = 0u32;
5235 let mut day = 1u32;
5236 loop {
5237 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5238 if d.weekday() == weekday {
5239 count += 1;
5240 if count == n {
5241 return d;
5242 }
5243 }
5244 day += 1;
5245 }
5246}
5247
5248fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5252 use chrono::{Datelike, TimeZone};
5253 let year = dt.year();
5254 let dst_start = chrono::Utc.from_utc_datetime(
5255 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5256 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5257 );
5258 let dst_end = chrono::Utc.from_utc_datetime(
5259 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5260 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5261 );
5262 dt >= dst_start && dt < dst_end
5263}
5264
5265fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5266 if is_pacific_dst(dt) {
5267 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5268 .format("%Y-%m-%d %H:%M PDT")
5269 .to_string()
5270 } else {
5271 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5272 .format("%Y-%m-%d %H:%M PST")
5273 .to_string()
5274 }
5275}
5276
5277fn fmt_git_date(iso: &str) -> Option<String> {
5278 chrono::DateTime::parse_from_rfc3339(iso)
5279 .ok()
5280 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5281}
5282
5283fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5284 reg.entries
5285 .iter()
5286 .map(|e| {
5287 let submodule_links = {
5288 let mut links: Vec<SubmoduleLinkRow> = vec![];
5289 let sub_dir = e
5290 .html_path
5291 .as_ref()
5292 .and_then(|p| p.parent())
5293 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5294 if let Some(dir) = sub_dir {
5295 if let Ok(rd) = std::fs::read_dir(dir) {
5296 for entry_res in rd.flatten() {
5297 let fname = entry_res.file_name();
5298 let fname_str = fname.to_string_lossy();
5299 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5300 let stem = &fname_str[..fname_str.len() - 5];
5301 let display = stem[4..].replace('-', " ");
5302 links.push(SubmoduleLinkRow {
5303 name: display,
5304 url: format!("/runs/{stem}/{}", e.run_id),
5305 });
5306 }
5307 }
5308 }
5309 }
5310 links.sort_by(|a, b| a.name.cmp(&b.name));
5311 links
5312 };
5313 let submodule_names_csv = submodule_links
5314 .iter()
5315 .map(|l| l.name.as_str())
5316 .collect::<Vec<_>>()
5317 .join(",");
5318 HistoryEntryRow {
5319 run_id: e.run_id.clone(),
5320 run_id_short: e
5321 .run_id
5322 .split('-')
5323 .next_back()
5324 .unwrap_or(&e.run_id)
5325 .chars()
5326 .take(7)
5327 .collect(),
5328 timestamp: fmt_la_time(e.timestamp_utc),
5329 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5330 project_label: e.project_label.clone(),
5331 project_path: e
5332 .input_roots
5333 .first()
5334 .map(|s| sanitize_path_str(s))
5335 .unwrap_or_default(),
5336 files_analyzed: e.summary.files_analyzed,
5337 files_skipped: e.summary.files_skipped,
5338 code_lines: e.summary.code_lines,
5339 comment_lines: e.summary.comment_lines,
5340 blank_lines: e.summary.blank_lines,
5341 git_branch: e.git_branch.clone().unwrap_or_default(),
5342 git_commit: e.git_commit.clone().unwrap_or_default(),
5343 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5344 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5345 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5346 submodule_links,
5347 submodule_names_csv,
5348 }
5349 })
5350 .collect()
5351}
5352
5353#[derive(Deserialize, Default)]
5354struct HistoryQuery {
5355 linked: Option<String>,
5356 error: Option<String>,
5357}
5358
5359async fn history_handler(
5360 State(state): State<AppState>,
5361 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5362 Query(query): Query<HistoryQuery>,
5363) -> impl IntoResponse {
5364 auto_scan_watched_dirs(&state).await;
5366 let watched_dirs: Vec<String> = {
5367 let wd = state.watched_dirs.lock().await;
5368 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5369 };
5370 let mut entries = {
5371 let reg = state.registry.lock().await;
5372 make_history_rows(®)
5373 };
5374 entries.retain(|e| e.has_html);
5375 let total_scans = entries.len();
5376 let linked_count = query
5377 .linked
5378 .as_deref()
5379 .and_then(|s| s.parse::<usize>().ok())
5380 .unwrap_or(0);
5381 let browse_error = query.error.filter(|s| !s.is_empty());
5382 let template = HistoryTemplate {
5383 version: env!("CARGO_PKG_VERSION"),
5384 entries,
5385 total_scans,
5386 linked_count,
5387 browse_error,
5388 watched_dirs,
5389 csp_nonce,
5390 server_mode: state.server_mode,
5391 };
5392 Html(
5393 template
5394 .render()
5395 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5396 )
5397 .into_response()
5398}
5399
5400async fn compare_select_handler(
5401 State(state): State<AppState>,
5402 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5403) -> impl IntoResponse {
5404 auto_scan_watched_dirs(&state).await;
5405 let watched_dirs: Vec<String> = {
5406 let wd = state.watched_dirs.lock().await;
5407 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5408 };
5409 let mut entries = {
5410 let reg = state.registry.lock().await;
5411 make_history_rows(®)
5412 };
5413 entries.retain(|e| e.has_json);
5414 let total_scans = entries.len();
5415 let template = CompareSelectTemplate {
5416 version: env!("CARGO_PKG_VERSION"),
5417 entries,
5418 total_scans,
5419 watched_dirs,
5420 csp_nonce,
5421 server_mode: state.server_mode,
5422 };
5423 Html(
5424 template
5425 .render()
5426 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5427 )
5428 .into_response()
5429}
5430
5431#[derive(Deserialize, Default)]
5434struct CompareQuery {
5435 a: Option<String>,
5436 b: Option<String>,
5437 sub: Option<String>,
5439 scope: Option<String>,
5441}
5442
5443struct CompareFileDeltaRow {
5444 relative_path: String,
5445 language: String,
5446 status: String,
5447 baseline_code: i64,
5448 current_code: i64,
5449 code_delta_str: String,
5450 code_delta_class: String,
5451 comment_delta_str: String,
5452 comment_delta_class: String,
5453 total_delta_str: String,
5454 total_delta_class: String,
5455}
5456
5457fn recompute_summary_from_records(run: &mut AnalysisRun) {
5460 let files_analyzed = run
5461 .per_file_records
5462 .iter()
5463 .filter(|r| r.language.is_some())
5464 .count() as u64;
5465 let code_lines: u64 = run
5466 .per_file_records
5467 .iter()
5468 .map(|r| r.effective_counts.code_lines)
5469 .sum();
5470 let comment_lines: u64 = run
5471 .per_file_records
5472 .iter()
5473 .map(|r| r.effective_counts.comment_lines)
5474 .sum();
5475 let blank_lines: u64 = run
5476 .per_file_records
5477 .iter()
5478 .map(|r| r.effective_counts.blank_lines)
5479 .sum();
5480 run.summary_totals.files_analyzed = files_analyzed;
5481 run.summary_totals.files_considered = files_analyzed;
5482 run.summary_totals.code_lines = code_lines;
5483 run.summary_totals.comment_lines = comment_lines;
5484 run.summary_totals.blank_lines = blank_lines;
5485 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5486}
5487
5488fn fmt_delta(n: i64) -> String {
5489 if n > 0 {
5490 format!("+{n}")
5491 } else {
5492 format!("{n}")
5493 }
5494}
5495
5496fn delta_class(n: i64) -> &'static str {
5497 use std::cmp::Ordering;
5498 match n.cmp(&0) {
5499 Ordering::Greater => "pos",
5500 Ordering::Less => "neg",
5501 Ordering::Equal => "zero",
5502 }
5503}
5504
5505#[allow(clippy::cast_precision_loss)]
5507fn fmt_pct(delta: i64, baseline: u64) -> String {
5508 if baseline == 0 {
5509 return "—".to_string();
5510 }
5511 #[allow(clippy::cast_precision_loss)]
5512 let pct = (delta as f64 / baseline as f64) * 100.0;
5513 if pct > 0.049 {
5514 format!("+{pct:.1}%")
5515 } else if pct < -0.049 {
5516 format!("{pct:.1}%")
5517 } else {
5518 "±0%".to_string()
5519 }
5520}
5521
5522fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5524 prev.map_or_else(
5525 || ("—".to_string(), "na"),
5526 |p| {
5527 #[allow(clippy::cast_possible_wrap)]
5528 let d = curr as i64 - p as i64;
5529 (fmt_delta(d), delta_class(d))
5530 },
5531 )
5532}
5533
5534#[allow(clippy::result_large_err)] fn load_scan_for_compare(
5536 json_path: &std::path::Path,
5537 scan_label: &str,
5538 run_id: &str,
5539 server_mode: bool,
5540 compare_url: &str,
5541 csp_nonce: &str,
5542) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5543 match read_json(json_path) {
5544 Ok(r) => Ok(r),
5545 Err(e) => {
5546 if server_mode {
5547 let html = ErrorTemplate {
5548 message: format!(
5549 "Could not load {scan_label} scan data. The scan output folder may have \
5550 been moved, renamed, or deleted. Re-running the analysis will create \
5551 fresh comparison data."
5552 ),
5553 last_report_url: Some("/compare-scans".to_string()),
5554 last_report_label: Some("Compare Scans".to_string()),
5555 csp_nonce: csp_nonce.to_owned(),
5556 version: env!("CARGO_PKG_VERSION"),
5557 }
5558 .render()
5559 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5560 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5561 }
5562 let msg = format!(
5563 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5564 json_path.display()
5565 );
5566 let folder_hint = json_path
5567 .parent()
5568 .map(|p| p.display().to_string())
5569 .unwrap_or_default();
5570 Err(missing_scan_relocate_response(
5571 &msg,
5572 run_id,
5573 &folder_hint,
5574 compare_url,
5575 false,
5576 csp_nonce,
5577 ))
5578 }
5579 }
5580}
5581
5582struct ChurnStats {
5583 new_scope: bool,
5584 scope_flag: bool,
5585 churn_rate_str: String,
5586 churn_rate_class: String,
5587}
5588
5589fn compute_churn_stats(
5590 baseline_code: u64,
5591 current_code: u64,
5592 lines_added: i64,
5593 lines_removed: i64,
5594) -> ChurnStats {
5595 let new_scope = baseline_code == 0 && current_code > 0;
5596 #[allow(clippy::cast_precision_loss)]
5597 let churn_pct = if baseline_code > 0 {
5598 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5599 } else {
5600 0.0
5601 };
5602 #[allow(clippy::cast_precision_loss)]
5603 let scope_flag =
5604 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5605 let churn_rate_str = if new_scope {
5606 "New".to_string()
5607 } else if baseline_code > 0 {
5608 format!("{churn_pct:.1}%")
5609 } else {
5610 "—".to_string()
5611 };
5612 let churn_rate_class = if new_scope || churn_pct > 20.0 {
5613 "high".to_string()
5614 } else if churn_pct > 5.0 {
5615 "med".to_string()
5616 } else {
5617 "low".to_string()
5618 };
5619 ChurnStats {
5620 new_scope,
5621 scope_flag,
5622 churn_rate_str,
5623 churn_rate_class,
5624 }
5625}
5626
5627fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5631 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5632 if !has_data {
5633 return String::new();
5634 }
5635 let base_str = s
5636 .baseline_coverage_line_pct
5637 .map(|p| format!("{p:.1}%"))
5638 .unwrap_or_else(|| "\u{2014}".into());
5639 let curr_str = s
5640 .current_coverage_line_pct
5641 .map(|p| format!("{p:.1}%"))
5642 .unwrap_or_else(|| "\u{2014}".into());
5643 let (delta_str, cls) = match s.coverage_line_pct_delta {
5644 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5645 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5646 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5647 None => ("\u{2014}".into(), "zero"),
5648 };
5649 format!(
5650 r#"<div class="delta-card">
5651 <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>
5652 <div class="delta-card-label">Line coverage</div>
5653 <div class="delta-card-from">Before: {base_str}</div>
5654 <div class="delta-card-to">{curr_str}</div>
5655 <span class="delta-card-change {cls}">{delta_str}</span>
5656 </div>"#
5657 )
5658}
5659
5660#[allow(clippy::too_many_lines)]
5661async fn compare_handler(
5662 State(state): State<AppState>,
5663 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5664 Query(query): Query<CompareQuery>,
5665) -> impl IntoResponse {
5666 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5669 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5670 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5671 };
5672
5673 let (maybe_a, maybe_b) = {
5674 let reg = state.registry.lock().await;
5675 (
5676 reg.find_by_run_id(&run_id_a).cloned(),
5677 reg.find_by_run_id(&run_id_b).cloned(),
5678 )
5679 };
5680
5681 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5682 let html = ErrorTemplate {
5683 message: "One or both run IDs were not found in scan history. \
5684 The runs may have been deleted or the registry may have been reset."
5685 .to_string(),
5686 last_report_url: Some("/compare-scans".to_string()),
5687 last_report_label: Some("Compare Scans".to_string()),
5688 csp_nonce: csp_nonce.clone(),
5689 version: env!("CARGO_PKG_VERSION"),
5690 }
5691 .render()
5692 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5693 return Html(html).into_response();
5694 };
5695
5696 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5698 (entry_a, entry_b)
5699 } else {
5700 (entry_b, entry_a)
5701 };
5702
5703 if baseline_entry.run_id != run_id_a {
5707 let canonical = format!(
5708 "/compare?a={}&b={}",
5709 baseline_entry.run_id, current_entry.run_id
5710 );
5711 return axum::response::Redirect::to(&canonical).into_response();
5712 }
5713
5714 let (Some(base_json), Some(curr_json)) = (
5715 baseline_entry.json_path.as_ref(),
5716 current_entry.json_path.as_ref(),
5717 ) else {
5718 let html = ErrorTemplate {
5719 message: "Full comparison requires JSON scan data, which was not saved for one or \
5720 both of these runs. JSON is now always saved for new scans — re-run the \
5721 affected projects to enable comparisons."
5722 .to_string(),
5723 last_report_url: Some("/compare-scans".to_string()),
5724 last_report_label: Some("Compare Scans".to_string()),
5725 csp_nonce: csp_nonce.clone(),
5726 version: env!("CARGO_PKG_VERSION"),
5727 }
5728 .render()
5729 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
5730 return Html(html).into_response();
5731 };
5732
5733 let compare_url = format!(
5734 "/compare?a={}&b={}",
5735 baseline_entry.run_id, current_entry.run_id
5736 );
5737
5738 let baseline_run = match load_scan_for_compare(
5739 base_json,
5740 "baseline",
5741 &baseline_entry.run_id,
5742 state.server_mode,
5743 &compare_url,
5744 &csp_nonce,
5745 ) {
5746 Ok(r) => r,
5747 Err(resp) => return resp,
5748 };
5749 let current_run = match load_scan_for_compare(
5750 curr_json,
5751 "current",
5752 ¤t_entry.run_id,
5753 state.server_mode,
5754 &compare_url,
5755 &csp_nonce,
5756 ) {
5757 Ok(r) => r,
5758 Err(resp) => return resp,
5759 };
5760
5761 let active_submodule = query.sub.clone();
5762 let super_scope_active = query.scope.as_deref() == Some("super");
5763
5764 let submodule_options = baseline_run
5765 .submodule_summaries
5766 .iter()
5767 .chain(current_run.submodule_summaries.iter())
5768 .map(|s| s.name.clone())
5769 .collect::<std::collections::BTreeSet<_>>()
5770 .into_iter()
5771 .collect::<Vec<_>>();
5772 let has_any_submodule_data = !submodule_options.is_empty();
5773
5774 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
5776 let mut b = baseline_run;
5777 let mut c = current_run;
5778 b.per_file_records
5779 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5780 c.per_file_records
5781 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5782 recompute_summary_from_records(&mut b);
5783 recompute_summary_from_records(&mut c);
5784 (b, c)
5785 } else if super_scope_active {
5786 let mut b = baseline_run;
5787 let mut c = current_run;
5788 b.per_file_records.retain(|f| f.submodule.is_none());
5789 c.per_file_records.retain(|f| f.submodule.is_none());
5790 recompute_summary_from_records(&mut b);
5791 recompute_summary_from_records(&mut c);
5792 (b, c)
5793 } else {
5794 (baseline_run, current_run)
5795 };
5796
5797 let comparison = compute_delta(&effective_baseline, &effective_current);
5798
5799 let file_rows: Vec<CompareFileDeltaRow> = comparison
5800 .file_deltas
5801 .iter()
5802 .map(|d| CompareFileDeltaRow {
5803 relative_path: d.relative_path.clone(),
5804 language: d.language.clone().unwrap_or_else(|| "—".into()),
5805 status: match d.status {
5806 FileChangeStatus::Added => "added".into(),
5807 FileChangeStatus::Removed => "removed".into(),
5808 FileChangeStatus::Modified => "modified".into(),
5809 FileChangeStatus::Unchanged => "unchanged".into(),
5810 },
5811 baseline_code: d.baseline_code,
5812 current_code: d.current_code,
5813 code_delta_str: fmt_delta(d.code_delta),
5814 code_delta_class: delta_class(d.code_delta).into(),
5815 comment_delta_str: fmt_delta(d.comment_delta),
5816 comment_delta_class: delta_class(d.comment_delta).into(),
5817 total_delta_str: fmt_delta(d.total_delta),
5818 total_delta_class: delta_class(d.total_delta).into(),
5819 })
5820 .collect();
5821
5822 let project_path = baseline_entry
5823 .input_roots
5824 .first()
5825 .map(|s| sanitize_path_str(s))
5826 .unwrap_or_default();
5827 let lines_added = sum_added_code_lines(&comparison);
5828 let lines_removed = sum_removed_code_lines(&comparison);
5829 let churn = compute_churn_stats(
5830 comparison.summary.baseline_code,
5831 comparison.summary.current_code,
5832 lines_added,
5833 lines_removed,
5834 );
5835 let s = &comparison.summary;
5836 let template = CompareTemplate {
5837 version: env!("CARGO_PKG_VERSION"),
5838 project_label: baseline_entry.project_label.clone(),
5839 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
5840 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
5841 baseline_run_id: baseline_entry.run_id.clone(),
5842 current_run_id: current_entry.run_id.clone(),
5843 baseline_run_id_short: baseline_entry
5844 .run_id
5845 .split('-')
5846 .next_back()
5847 .unwrap_or(&baseline_entry.run_id)
5848 .chars()
5849 .take(7)
5850 .collect(),
5851 current_run_id_short: current_entry
5852 .run_id
5853 .split('-')
5854 .next_back()
5855 .unwrap_or(¤t_entry.run_id)
5856 .chars()
5857 .take(7)
5858 .collect(),
5859 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5860 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5861 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5862 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5863 project_path: project_path.clone(),
5864 baseline_code: s.baseline_code,
5865 current_code: s.current_code,
5866 code_lines_delta_str: fmt_delta(s.code_lines_delta),
5867 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5868 baseline_files: s.baseline_files,
5869 current_files: s.current_files,
5870 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5871 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5872 baseline_comments: s.baseline_comments,
5873 current_comments: s.current_comments,
5874 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5875 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5876 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5877 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5878 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5879 code_lines_added: lines_added,
5880 code_lines_removed: lines_removed,
5881 new_scope: churn.new_scope,
5882 churn_rate_str: churn.churn_rate_str,
5883 churn_rate_class: churn.churn_rate_class,
5884 scope_flag: churn.scope_flag,
5885 files_added: comparison.files_added,
5886 files_removed: comparison.files_removed,
5887 files_modified: comparison.files_modified,
5888 files_unchanged: comparison.files_unchanged,
5889 file_rows,
5890 baseline_git_author: baseline_entry.git_author.clone(),
5891 current_git_author: current_entry.git_author.clone(),
5892 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5893 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5894 baseline_git_tags: baseline_entry.git_tags.clone(),
5895 current_git_tags: current_entry.git_tags.clone(),
5896 baseline_git_commit_date: baseline_entry
5897 .git_commit_date
5898 .as_deref()
5899 .and_then(fmt_git_date),
5900 current_git_commit_date: current_entry
5901 .git_commit_date
5902 .as_deref()
5903 .and_then(fmt_git_date),
5904 project_name: project_path
5905 .rsplit(['/', '\\'])
5906 .find(|s| !s.is_empty())
5907 .unwrap_or(&project_path)
5908 .to_string(),
5909 submodule_options,
5910 has_any_submodule_data,
5911 active_submodule,
5912 super_scope_active,
5913 csp_nonce,
5914 coverage_delta_card: build_coverage_delta_card(s),
5915 };
5916
5917 Html(
5918 template
5919 .render()
5920 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5921 )
5922 .into_response()
5923}
5924
5925fn format_number(n: u64) -> String {
5933 let s = n.to_string();
5934 let mut out = String::with_capacity(s.len() + s.len() / 3);
5935 let len = s.len();
5936 for (i, c) in s.chars().enumerate() {
5937 if i > 0 && (len - i).is_multiple_of(3) {
5938 out.push(',');
5939 }
5940 out.push(c);
5941 }
5942 out
5943}
5944
5945const fn badge_char_width(c: char) -> f64 {
5946 match c {
5947 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5948 'm' | 'w' => 9.0,
5949 ' ' => 4.0,
5950 _ => 6.5,
5951 }
5952}
5953
5954#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5955fn badge_text_px(text: &str) -> u32 {
5956 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5957}
5958
5959fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5960 let lw = badge_text_px(label) + 20;
5961 let rw = badge_text_px(value) + 20;
5962 let total = lw + rw;
5963 let lx = lw / 2;
5964 let rx = lw + rw / 2;
5965 let le = escape_html(label);
5966 let ve = escape_html(value);
5967 let ce = escape_html(color);
5968 format!(
5969 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
5970 <rect width="{total}" height="20" fill="#555"/>
5971 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
5972 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
5973 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
5974 <text x="{lx}" y="13">{le}</text>
5975 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
5976 <text x="{rx}" y="13">{ve}</text>
5977 </g>
5978</svg>"##
5979 )
5980}
5981
5982#[derive(Deserialize)]
5983struct BadgeQuery {
5984 label: Option<String>,
5985 color: Option<String>,
5986}
5987
5988async fn badge_handler(
5989 State(state): State<AppState>,
5990 AxumPath(metric): AxumPath<String>,
5991 Query(query): Query<BadgeQuery>,
5992) -> Response {
5993 let entry = {
5994 let reg = state.registry.lock().await;
5995 reg.entries.first().cloned()
5996 };
5997
5998 let Some(entry) = entry else {
5999 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6000 return (
6001 [
6002 (header::CONTENT_TYPE, "image/svg+xml"),
6003 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6004 ],
6005 svg,
6006 )
6007 .into_response();
6008 };
6009
6010 let (default_label, value, default_color) = match metric.as_str() {
6011 "code-lines" => (
6012 "code lines",
6013 format_number(entry.summary.code_lines),
6014 "#4a78ee",
6015 ),
6016 "files" => (
6017 "files analyzed",
6018 format_number(entry.summary.files_analyzed),
6019 "#4a9862",
6020 ),
6021 "comment-lines" => (
6022 "comment lines",
6023 format_number(entry.summary.comment_lines),
6024 "#b35428",
6025 ),
6026 "blank-lines" => (
6027 "blank lines",
6028 format_number(entry.summary.blank_lines),
6029 "#7a5db0",
6030 ),
6031 _ => return StatusCode::NOT_FOUND.into_response(),
6032 };
6033
6034 let label = query.label.as_deref().unwrap_or(default_label);
6035 let color = query.color.as_deref().unwrap_or(default_color);
6036 let svg = render_badge_svg(label, &value, color);
6037
6038 (
6039 [
6040 (header::CONTENT_TYPE, "image/svg+xml"),
6041 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6042 ],
6043 svg,
6044 )
6045 .into_response()
6046}
6047
6048#[derive(Serialize)]
6056struct ApiCoverageBlock {
6057 lines_found: u64,
6058 lines_hit: u64,
6059 line_pct: f64,
6060 functions_found: u64,
6061 functions_hit: u64,
6062 function_pct: f64,
6063 branches_found: u64,
6064 branches_hit: u64,
6065 branch_pct: f64,
6066}
6067
6068#[derive(Serialize)]
6069struct ApiMetricsResponse {
6070 run_id: String,
6071 timestamp: String,
6072 project: String,
6073 summary: ApiSummaryPayload,
6074 languages: Vec<ApiLanguageRow>,
6075 #[serde(skip_serializing_if = "Option::is_none")]
6076 coverage: Option<ApiCoverageBlock>,
6077}
6078
6079#[derive(Serialize)]
6080struct ApiSummaryPayload {
6081 files_analyzed: u64,
6082 files_skipped: u64,
6083 code_lines: u64,
6084 comment_lines: u64,
6085 blank_lines: u64,
6086 total_physical_lines: u64,
6087 functions: u64,
6088 classes: u64,
6089 variables: u64,
6090 imports: u64,
6091}
6092
6093#[derive(Serialize)]
6094struct ApiLanguageRow {
6095 name: String,
6096 files: u64,
6097 code_lines: u64,
6098 comment_lines: u64,
6099 blank_lines: u64,
6100 functions: u64,
6101 classes: u64,
6102 variables: u64,
6103 imports: u64,
6104}
6105
6106async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6107 let entry = {
6108 let reg = state.registry.lock().await;
6109 reg.entries.first().cloned()
6110 };
6111 entry.map_or_else(
6112 || error::not_found("no scans recorded yet"),
6113 |e| build_metrics_response(&e),
6114 )
6115}
6116
6117async fn api_metrics_run_handler(
6118 State(state): State<AppState>,
6119 AxumPath(run_id): AxumPath<String>,
6120) -> Response {
6121 let entry = {
6122 let reg = state.registry.lock().await;
6123 reg.find_by_run_id(&run_id).cloned()
6124 };
6125 entry.map_or_else(
6126 || error::not_found("run not found"),
6127 |e| build_metrics_response(&e),
6128 )
6129}
6130
6131fn build_metrics_response(entry: &RegistryEntry) -> Response {
6132 let languages: Vec<ApiLanguageRow> = entry
6133 .json_path
6134 .as_ref()
6135 .and_then(|p| read_json(p).ok())
6136 .map(|run| {
6137 run.totals_by_language
6138 .iter()
6139 .map(|l| ApiLanguageRow {
6140 name: l.language.display_name().to_string(),
6141 files: l.files,
6142 code_lines: l.code_lines,
6143 comment_lines: l.comment_lines,
6144 blank_lines: l.blank_lines,
6145 functions: l.functions,
6146 classes: l.classes,
6147 variables: l.variables,
6148 imports: l.imports,
6149 })
6150 .collect()
6151 })
6152 .unwrap_or_default();
6153
6154 let s = &entry.summary;
6155 let coverage = if s.coverage_lines_found > 0 {
6156 let pct = |hit: u64, found: u64| -> f64 {
6157 if found == 0 {
6158 0.0
6159 } else {
6160 #[allow(clippy::cast_precision_loss)]
6161 let v = (hit as f64 / found as f64) * 100.0;
6162 (v * 10.0).round() / 10.0
6163 }
6164 };
6165 Some(ApiCoverageBlock {
6166 lines_found: s.coverage_lines_found,
6167 lines_hit: s.coverage_lines_hit,
6168 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6169 functions_found: s.coverage_functions_found,
6170 functions_hit: s.coverage_functions_hit,
6171 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6172 branches_found: s.coverage_branches_found,
6173 branches_hit: s.coverage_branches_hit,
6174 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6175 })
6176 } else {
6177 None
6178 };
6179 Json(ApiMetricsResponse {
6180 run_id: entry.run_id.clone(),
6181 timestamp: entry.timestamp_utc.to_rfc3339(),
6182 project: entry.project_label.clone(),
6183 summary: ApiSummaryPayload {
6184 files_analyzed: s.files_analyzed,
6185 files_skipped: s.files_skipped,
6186 code_lines: s.code_lines,
6187 comment_lines: s.comment_lines,
6188 blank_lines: s.blank_lines,
6189 total_physical_lines: s.total_physical_lines,
6190 functions: s.functions,
6191 classes: s.classes,
6192 variables: s.variables,
6193 imports: s.imports,
6194 },
6195 languages,
6196 coverage,
6197 })
6198 .into_response()
6199}
6200
6201#[derive(Deserialize)]
6208struct ProjectHistoryQuery {
6209 path: Option<String>,
6210}
6211
6212#[derive(Serialize)]
6213struct ProjectHistoryResponse {
6214 scan_count: usize,
6215 last_scan_id: Option<String>,
6216 last_scan_timestamp: Option<String>,
6217 last_scan_code_lines: Option<u64>,
6218 last_git_branch: Option<String>,
6219 last_git_commit: Option<String>,
6220}
6221
6222fn entry_matches_project(
6225 entry: &RegistryEntry,
6226 root_str: &str,
6227 upload_root: &str,
6228 upload_name_suffix: Option<&str>,
6229) -> bool {
6230 if entry.input_roots.iter().any(|r| r == root_str) {
6231 return true;
6232 }
6233 if let Some(suffix) = upload_name_suffix {
6234 return entry
6235 .input_roots
6236 .iter()
6237 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6238 }
6239 false
6240}
6241
6242async fn project_history_handler(
6243 State(state): State<AppState>,
6244 Query(query): Query<ProjectHistoryQuery>,
6245) -> Response {
6246 let path = query.path.unwrap_or_default();
6247 let resolved = resolve_input_path(&path);
6248 let root_str = resolved.to_string_lossy().replace('\\', "/");
6249
6250 let upload_root = std::env::temp_dir()
6255 .join("oxide-sloc-uploads")
6256 .to_string_lossy()
6257 .replace('\\', "/");
6258 let upload_name_suffix: Option<String> =
6259 if state.server_mode && root_str.starts_with(&upload_root) {
6260 resolved
6261 .file_name()
6262 .and_then(|n| n.to_str())
6263 .map(|name| format!("/{name}"))
6264 } else {
6265 None
6266 };
6267 let suffix_ref = upload_name_suffix.as_deref();
6268
6269 let entries: Vec<_> = {
6270 let reg = state.registry.lock().await;
6271 reg.entries
6272 .iter()
6273 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6274 .cloned()
6275 .collect()
6276 };
6277 let scan_count = entries.len();
6278 let last = entries.first();
6279 let last_scan_id = last.map(|e| e.run_id.clone());
6280 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6281 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6282 let last_git_branch = last.and_then(|e| e.git_branch.clone());
6283 let last_git_commit = last.and_then(|e| e.git_commit.clone());
6284
6285 Json(ProjectHistoryResponse {
6286 scan_count,
6287 last_scan_id,
6288 last_scan_timestamp,
6289 last_scan_code_lines,
6290 last_git_branch,
6291 last_git_commit,
6292 })
6293 .into_response()
6294}
6295
6296#[derive(Deserialize)]
6303struct MetricsHistoryQuery {
6304 root: Option<String>,
6305 limit: Option<usize>,
6306 submodule: Option<String>,
6309}
6310
6311#[derive(Serialize)]
6312struct MetricsSubmoduleLink {
6313 name: String,
6314 url: String,
6315}
6316
6317#[derive(Serialize)]
6318struct MetricsHistoryEntry {
6319 run_id: String,
6320 run_id_short: String,
6321 timestamp: String,
6322 commit: Option<String>,
6323 branch: Option<String>,
6324 tags: Vec<String>,
6325 nearest_tag: Option<String>,
6326 code_lines: u64,
6327 comment_lines: u64,
6328 blank_lines: u64,
6329 physical_lines: u64,
6330 files_analyzed: u64,
6331 files_skipped: u64,
6332 test_count: u64,
6333 project_label: String,
6334 html_url: Option<String>,
6335 has_pdf: bool,
6336 submodule_links: Vec<MetricsSubmoduleLink>,
6337 #[serde(skip_serializing_if = "Option::is_none")]
6339 coverage_line_pct: Option<f64>,
6340}
6341
6342fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6343 let mut links: Vec<MetricsSubmoduleLink> = vec![];
6344 let sub_dir = e
6345 .html_path
6346 .as_ref()
6347 .and_then(|p| p.parent())
6348 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6349 let Some(dir) = sub_dir else { return links };
6350 let Ok(rd) = std::fs::read_dir(dir) else {
6351 return links;
6352 };
6353 for entry_res in rd.flatten() {
6354 let fname = entry_res.file_name();
6355 let fname_str = fname.to_string_lossy();
6356 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6357 let stem = &fname_str[..fname_str.len() - 5];
6358 let display = stem[4..].replace('-', " ");
6359 links.push(MetricsSubmoduleLink {
6360 name: display,
6361 url: format!("/runs/{stem}/{}", e.run_id),
6362 });
6363 }
6364 }
6365 links.sort_by(|a, b| a.name.cmp(&b.name));
6366 links
6367}
6368
6369fn apply_submodule_filter(
6370 base: MetricsHistoryEntry,
6371 filter: &str,
6372 e: &sloc_core::history::RegistryEntry,
6373) -> Option<MetricsHistoryEntry> {
6374 let json_path = e.json_path.as_ref()?;
6375 let json_str = std::fs::read_to_string(json_path).ok()?;
6376 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6377 let sub = run
6378 .submodule_summaries
6379 .iter()
6380 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6381 let safe = sanitize_project_label(&sub.name);
6382 let artifact_key = format!("sub_{safe}");
6383 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6384 || base.html_url.clone(),
6385 |run_dir| {
6386 let sub_path = run_dir.join(format!("{artifact_key}.html"));
6387 if sub_path.exists() {
6388 Some(format!("/runs/{artifact_key}/{}", e.run_id))
6389 } else {
6390 base.html_url.clone()
6391 }
6392 },
6393 );
6394 Some(MetricsHistoryEntry {
6395 code_lines: sub.code_lines,
6396 comment_lines: sub.comment_lines,
6397 blank_lines: sub.blank_lines,
6398 physical_lines: sub.total_physical_lines,
6399 files_analyzed: sub.files_analyzed,
6400 html_url: sub_html_url,
6401 has_pdf: false,
6402 submodule_links: vec![],
6403 ..base
6404 })
6405}
6406
6407#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
6409 State(state): State<AppState>,
6410 Query(query): Query<MetricsHistoryQuery>,
6411) -> Response {
6412 let limit = query.limit.unwrap_or(50).min(500);
6413 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6414
6415 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6416 let reg = state.registry.lock().await;
6417 reg.entries
6418 .iter()
6419 .filter(|e| {
6420 query.root.as_ref().is_none_or(|root| {
6421 let resolved = resolve_input_path(root);
6422 let root_str = resolved.to_string_lossy().replace('\\', "/");
6423 e.input_roots.iter().any(|r| r == &root_str)
6424 })
6425 })
6426 .take(limit)
6427 .cloned()
6428 .collect()
6429 };
6430
6431 let entries: Vec<MetricsHistoryEntry> = candidate_entries
6432 .into_iter()
6433 .filter_map(|e| {
6434 let tags = e
6435 .git_tags
6436 .as_deref()
6437 .map(|s| {
6438 s.split(',')
6439 .map(|t| t.trim().to_string())
6440 .filter(|t| !t.is_empty())
6441 .collect()
6442 })
6443 .unwrap_or_default();
6444 let html_url = e
6445 .html_path
6446 .as_ref()
6447 .filter(|p| p.exists())
6448 .map(|_| format!("/runs/html/{}", e.run_id));
6449 let nearest_tag = e.git_nearest_tag.clone();
6450 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6451 let run_id_short: String = e
6452 .run_id
6453 .split('-')
6454 .next_back()
6455 .unwrap_or(&e.run_id)
6456 .chars()
6457 .take(7)
6458 .collect();
6459 let submodule_links = build_entry_submodule_links(&e);
6460 #[allow(clippy::cast_precision_loss)]
6461 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6462 let pct = (e.summary.coverage_lines_hit as f64
6463 / e.summary.coverage_lines_found as f64)
6464 * 100.0;
6465 Some((pct * 10.0).round() / 10.0)
6466 } else {
6467 None
6468 };
6469 let base = MetricsHistoryEntry {
6470 run_id: e.run_id.clone(),
6471 run_id_short,
6472 timestamp: e.timestamp_utc.to_rfc3339(),
6473 commit: e.git_commit.clone(),
6474 branch: e.git_branch.clone(),
6475 tags,
6476 nearest_tag,
6477 code_lines: e.summary.code_lines,
6478 comment_lines: e.summary.comment_lines,
6479 blank_lines: e.summary.blank_lines,
6480 physical_lines: e.summary.total_physical_lines,
6481 files_analyzed: e.summary.files_analyzed,
6482 files_skipped: e.summary.files_skipped,
6483 test_count: e.summary.test_count,
6484 project_label: e.project_label.clone(),
6485 html_url,
6486 has_pdf,
6487 submodule_links,
6488 coverage_line_pct,
6489 };
6490 if let Some(ref filter) = submodule_filter {
6491 apply_submodule_filter(base, filter, &e)
6492 } else {
6493 Some(base)
6494 }
6495 })
6496 .collect();
6497
6498 Json(entries).into_response()
6499}
6500
6501#[derive(Deserialize)]
6505struct MetricsSubmodulesQuery {
6506 root: Option<String>,
6507}
6508
6509#[derive(Serialize)]
6510struct SubmoduleEntry {
6511 name: String,
6512 relative_path: String,
6513}
6514
6515async fn api_metrics_submodules_handler(
6516 State(state): State<AppState>,
6517 Query(query): Query<MetricsSubmodulesQuery>,
6518) -> Response {
6519 let json_paths: Vec<std::path::PathBuf> = {
6520 let reg = state.registry.lock().await;
6521 reg.entries
6522 .iter()
6523 .filter(|e| {
6524 query.root.as_ref().is_none_or(|root| {
6525 let resolved = resolve_input_path(root);
6526 let root_str = resolved.to_string_lossy().replace('\\', "/");
6527 e.input_roots.iter().any(|r| r == &root_str)
6528 })
6529 })
6530 .filter_map(|e| e.json_path.clone())
6531 .collect()
6532 };
6533
6534 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6535 let mut result: Vec<SubmoduleEntry> = Vec::new();
6536
6537 for path in &json_paths {
6538 let Ok(json_str) = std::fs::read_to_string(path) else {
6539 continue;
6540 };
6541 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6542 continue;
6543 };
6544 for sub in &run.submodule_summaries {
6545 if seen.insert(sub.name.clone()) {
6546 result.push(SubmoduleEntry {
6547 name: sub.name.clone(),
6548 relative_path: sub.relative_path.clone(),
6549 });
6550 }
6551 }
6552 }
6553
6554 result.sort_by(|a, b| a.name.cmp(&b.name));
6555 Json(result).into_response()
6556}
6557
6558#[derive(Deserialize)]
6567struct IngestQuery {
6568 label: Option<String>,
6569}
6570
6571#[derive(Serialize)]
6572struct IngestResponse {
6573 run_id: String,
6574 view_url: String,
6575}
6576
6577async fn api_ingest_handler(
6578 State(state): State<AppState>,
6579 Query(q): Query<IngestQuery>,
6580 Json(run): Json<sloc_core::AnalysisRun>,
6581) -> Response {
6582 let label = q.label.unwrap_or_else(|| {
6583 run.input_roots
6584 .first()
6585 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6586 });
6587
6588 let label_for_task = label.clone();
6589 let result = tokio::task::spawn_blocking(move || {
6590 let html = render_html(&run)?;
6591 let run_id = run.tool.run_id.clone();
6592 let run_id_safe = run_id.len() <= 128
6593 && !run_id.is_empty()
6594 && run_id
6595 .chars()
6596 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6597 if !run_id_safe {
6598 anyhow::bail!(
6599 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6600 );
6601 }
6602 let project_label = sanitize_project_label(&label_for_task);
6603 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6604 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6605 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6606 _ => project_label,
6607 };
6608 let (artifacts, _pending_pdf) = persist_run_artifacts(
6609 &run,
6610 &html,
6611 &output_dir,
6612 true,
6613 true,
6614 false,
6615 &label_for_task,
6616 &file_stem,
6617 RunResultContext::default(),
6618 )?;
6619 Ok::<_, anyhow::Error>((run_id, artifacts, run))
6620 })
6621 .await;
6622
6623 match result {
6624 Ok(Ok((run_id, artifacts, run))) => {
6625 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6626 (
6627 StatusCode::CREATED,
6628 Json(IngestResponse {
6629 view_url: format!("/view-reports?run_id={run_id}"),
6630 run_id,
6631 }),
6632 )
6633 .into_response()
6634 }
6635 Ok(Err(e)) => error::internal(&format!("{e:#}")),
6636 Err(e) => error::internal(&format!("{e}")),
6637 }
6638}
6639
6640#[allow(clippy::too_many_lines)] async fn trend_report_handler(
6648 State(state): State<AppState>,
6649 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6650) -> Response {
6651 auto_scan_watched_dirs(&state).await;
6652
6653 let watched_dirs_list: Vec<String> = {
6654 let wd = state.watched_dirs.lock().await;
6655 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6656 };
6657
6658 let roots: Vec<String> = {
6660 let reg = state.registry.lock().await;
6661 let mut seen = std::collections::BTreeSet::new();
6662 reg.entries
6663 .iter()
6664 .flat_map(|e| e.input_roots.iter().cloned())
6665 .filter(|r| seen.insert(r.clone()))
6666 .collect()
6667 };
6668
6669 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6670 let nonce = &csp_nonce;
6671 let version = env!("CARGO_PKG_VERSION");
6672
6673 let watched_dirs_html: String = if state.server_mode {
6677 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()
6678 } else {
6679 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6680 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6681 .to_string()
6682 } else {
6683 watched_dirs_list
6684 .iter()
6685 .fold(String::new(), |mut s, d| {
6686 use std::fmt::Write as _;
6687 let escaped =
6688 d.replace('&', "&").replace('"', """).replace('<', "<");
6689 write!(
6690 s,
6691 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>"#
6692 ).expect("write to String is infallible");
6693 s
6694 })
6695 };
6696 format!(
6697 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>"#
6698 )
6699 };
6700
6701 let html = format!(
6702 r##"<!doctype html>
6703<html lang="en">
6704<head>
6705 <meta charset="utf-8" />
6706 <meta name="viewport" content="width=device-width, initial-scale=1" />
6707 <title>OxideSLOC | Trend Reports</title>
6708 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6709 <style nonce="{nonce}">
6710 :root {{
6711 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6712 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6713 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6714 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6715 --info-bg:#eef3ff; --info-text:#4467d8;
6716 }}
6717 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
6718 *{{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;}}
6719 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
6720 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
6721 .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;}}
6722 @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));}}}}
6723 .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);}}
6724 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
6725 .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));}}
6726 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
6727 .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;}}
6728 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
6729 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
6730 @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; }} }}
6731 .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;}}
6732 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
6733 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
6734 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
6735 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
6736 .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;}}
6737 .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;}}
6738 .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;}}
6739 .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;}}
6740 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
6741 .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);}}
6742 .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;}}
6743 .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;}}
6744 .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;}}
6745 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
6746 .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;}}
6747 .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);}}
6748 .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;}}
6749 .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;}}
6750 .tz-select:focus{{border-color:var(--oxide);}}
6751 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
6752 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
6753 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
6754 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
6755 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
6756 .trend-title-block{{flex:1;min-width:0;}}
6757 .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;}}
6758 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
6759 .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;}}
6760 .chart-select:focus{{border-color:var(--accent);}}
6761 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
6762 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
6763 .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;}}
6764 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
6765 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
6766 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
6767 .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);}}
6768 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
6769 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
6770 .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;}}
6771 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
6772 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
6773 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
6774 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
6775 .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;}}
6776 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
6777 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
6778 .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);}}
6779 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
6780 .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;}}
6781 .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;}}
6782 .data-table tr:last-child td{{border-bottom:none;}}
6783 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
6784 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
6785 .table-wrap{{width:100%;overflow-x:auto;}}
6786 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
6787 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
6788 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
6789 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
6790 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
6791 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
6792 .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;}}
6793 .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;}}
6794 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
6795 .pagination-info{{font-size:13px;color:var(--muted);}}
6796 .pagination-btns{{display:flex;gap:6px;}}
6797 .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;}}
6798 .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;}}
6799 #scan-history-table col:nth-child(1){{width:155px;}}
6800 #scan-history-table col:nth-child(2){{width:240px;}}
6801 #scan-history-table col:nth-child(3){{width:82px;}}
6802 #scan-history-table col:nth-child(4){{width:82px;}}
6803 #scan-history-table col:nth-child(5){{width:90px;}}
6804 #scan-history-table col:nth-child(6){{width:90px;}}
6805 #scan-history-table col:nth-child(7){{width:88px;}}
6806 #scan-history-table col:nth-child(8){{width:150px;}}
6807 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
6808 .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;}}
6809 .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;}}
6810 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
6811 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
6812 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
6813 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
6814 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
6815 .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;}}
6816 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
6817 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
6818 .watched-chip-rm:hover{{color:var(--oxide);}}
6819 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
6820 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
6821 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
6822 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
6823 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
6824 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
6825 a.run-link:hover{{text-decoration:underline;}}
6826 .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);}}
6827 .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);}}
6828 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
6829 .metric-num{{font-weight:700;color:var(--text);}}
6830 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
6831 .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;}}
6832 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
6833 .btn.primary:hover{{opacity:.9;}}
6834 .rpt-btn{{min-width:58px;justify-content:center;}}
6835 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
6836 .report-cell{{overflow:visible!important;white-space:normal!important;}}
6837 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
6838 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
6839 .submod-details summary::-webkit-details-marker{{display:none;}}
6840 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
6841 .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;}}
6842 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
6843 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
6844 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
6845 .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;}}
6846 .export-btn:hover{{background:var(--line);}}
6847 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
6848 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
6849 .site-footer a{{color:var(--muted);}}
6850 .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;}}
6851 .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;}}
6852 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
6853 </style>
6854</head>
6855<body>
6856 <div class="background-watermarks" aria-hidden="true">
6857 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6858 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6859 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6860 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6861 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6862 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6863 </div>
6864 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6865 <div class="top-nav">
6866 <div class="top-nav-inner">
6867 <a class="brand" href="/">
6868 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6869 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
6870 </a>
6871 <div class="nav-right">
6872 <a class="nav-pill" href="/">Home</a>
6873 <div class="nav-dropdown">
6874 <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>
6875 <div class="nav-dropdown-menu">
6876 <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>
6877 </div>
6878 </div>
6879 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6880 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
6881 <div class="nav-dropdown">
6882 <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>
6883 <div class="nav-dropdown-menu">
6884 <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>
6885 </div>
6886 </div>
6887 <div class="server-status-wrap" id="server-status-wrap">
6888 <div class="nav-pill server-online-pill" id="server-status-pill">
6889 <span class="status-dot" id="status-dot"></span>
6890 <span id="server-status-label">Server</span>
6891 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
6892 </div>
6893 <div class="server-status-tip">
6894 OxideSLOC is running — accessible on your network.
6895 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
6896 </div>
6897 </div>
6898 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6899 <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>
6900 </button>
6901 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6902 <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>
6903 <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>
6904 </button>
6905 </div>
6906 </div>
6907 </div>
6908
6909 <div class="page">
6910 {watched_dirs_html}
6911 <div class="summary-strip" id="trend-stats"></div>
6912 <div class="panel">
6913 <div class="trend-header">
6914 <div class="trend-title-block">
6915 <h1>Trend Reports</h1>
6916 <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>
6917 <span class="chart-hint-inline">
6918 <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>
6919 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
6920 </span>
6921 </div>
6922 <div class="chart-actions">
6923 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
6924 <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>
6925 Clean up old runs
6926 </button>
6927 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
6928 <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>
6929 Export Excel
6930 </button>
6931 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
6932 <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>
6933 Export PNG
6934 </button>
6935 </div>
6936 </div>
6937
6938 <div class="controls-centered">
6939 <label>Project Root:
6940 <select class="chart-select" id="root-sel">
6941 <option value="">All projects</option>
6942 </select>
6943 </label>
6944 <label>Y Metric:
6945 <select class="chart-select" id="y-sel">
6946 <option value="code_lines">Code Lines</option>
6947 <option value="comment_lines">Comment Lines</option>
6948 <option value="blank_lines">Blank Lines</option>
6949 <option value="physical_lines">Physical Lines</option>
6950 <option value="files_analyzed">Files Analyzed</option>
6951 </select>
6952 </label>
6953 <label>X Axis:
6954 <select class="chart-select" id="x-sel">
6955 <option value="time">By Time</option>
6956 <option value="commit">By Commit</option>
6957 <option value="release">By Release</option>
6958 <option value="tag">Tagged Commits</option>
6959 </select>
6960 </label>
6961 <label id="submodule-label" style="display:none;">Submodule:
6962 <select class="chart-select" id="sub-sel">
6963 <option value="">All (project total)</option>
6964 </select>
6965 </label>
6966 <label>Chart Size:
6967 <select class="chart-select" id="scale-sel">
6968 <option value="0.75">Compact</option>
6969 <option value="1.2" selected>Normal</option>
6970 <option value="1.38">Large</option>
6971 </select>
6972 </label>
6973 </div>
6974
6975 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
6976 <div id="data-table-wrap" style="overflow-x:auto;"></div>
6977 </div>
6978 </div>
6979
6980 <script nonce="{nonce}">
6981 (function() {{
6982 // Theme persistence
6983 var b = document.body;
6984 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
6985 var tgl = document.getElementById('theme-toggle');
6986 if (tgl) tgl.addEventListener('click', function() {{
6987 var d = b.classList.toggle('dark-theme');
6988 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
6989 }});
6990
6991 // Watermark randomizer
6992 (function() {{
6993 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6994 if (!wms.length) return;
6995 var placed = [];
6996 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;}}
6997 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];}}
6998 var half=Math.floor(wms.length/2);
6999 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;}});
7000 }})();
7001
7002 // Code particles
7003 (function() {{
7004 var container = document.getElementById('code-particles');
7005 if (!container) return;
7006 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'];
7007 for (var i = 0; i < 38; i++) {{
7008 (function(idx) {{
7009 var el = document.createElement('span');
7010 el.className = 'code-particle';
7011 el.textContent = snippets[idx % snippets.length];
7012 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7013 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7014 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7015 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';
7016 container.appendChild(el);
7017 }})(i);
7018 }}
7019 }})();
7020
7021 // Watched folder picker
7022 (function() {{
7023 var btn = document.getElementById('add-watched-btn');
7024 if (!btn) return;
7025 btn.addEventListener('click', function() {{
7026 fetch('/pick-directory?kind=reports')
7027 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7028 .then(function(data) {{
7029 if (!data.cancelled && data.selected_path) {{
7030 var form = document.createElement('form');
7031 form.method = 'POST';
7032 form.action = '/watched-dirs/add';
7033 var ri = document.createElement('input');
7034 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7035 var fi = document.createElement('input');
7036 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7037 form.appendChild(ri); form.appendChild(fi);
7038 document.body.appendChild(form);
7039 form.submit();
7040 }}
7041 }})
7042 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7043 }});
7044 }})();
7045
7046 // Settings / color-scheme modal
7047 (function() {{
7048 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'}}];
7049 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);}});}}
7050 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7051 var btn=document.getElementById('settings-btn');if(!btn)return;
7052 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7053 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>';
7054 document.body.appendChild(m);
7055 var g=document.getElementById('scheme-grid');
7056 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);}});
7057 var cl=document.getElementById('settings-close');
7058 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);
7059 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');}});
7060 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7061 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7062 }})();
7063 }})();
7064
7065 var ROOTS = {roots_json};
7066 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7067 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7068 var allData = [];
7069
7070 // Populate root selector
7071 var rootSel = document.getElementById('root-sel');
7072 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7073
7074 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();}}
7075 function fmtFull(n){{return Number(n).toLocaleString();}}
7076 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
7077
7078 // Tooltip
7079 var tt = document.createElement('div');
7080 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);';
7081 document.body.appendChild(tt);
7082 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7083 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';}}
7084 function hideTT(){{tt.style.display='none';}}
7085
7086 function statExact(compact, full){{
7087 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7088 }}
7089 function statVal(n){{
7090 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7091 }}
7092
7093 function updateStats(data){{
7094 var statsEl=document.getElementById('trend-stats');
7095 if(!statsEl)return;
7096 if(!data||!data.length){{statsEl.innerHTML='';return;}}
7097 var yKey=document.getElementById('y-sel').value;
7098 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7099 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7100 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7101 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7102 var absDelta=Math.abs(delta);
7103 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7104 var deltaExact=statExact(deltaCompact,deltaFull);
7105 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7106 statsEl.innerHTML=
7107 '<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>'+
7108 '<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>'+
7109 '<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>'+
7110 '<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>';
7111 }}
7112
7113 var subSel = document.getElementById('sub-sel');
7114 var subLabel = document.getElementById('submodule-label');
7115
7116 function populateSubmodules(root){{
7117 if(!subSel||!subLabel)return;
7118 while(subSel.options.length>1)subSel.remove(1);
7119 subSel.value='';
7120 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7121 fetch(url)
7122 .then(function(r){{return r.json();}})
7123 .then(function(subs){{
7124 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7125 subs.forEach(function(s){{
7126 var o=document.createElement('option');
7127 o.value=s.name;
7128 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7129 subSel.appendChild(o);
7130 }});
7131 subLabel.style.display='';
7132 }})
7133 .catch(function(){{subLabel.style.display='none';}});
7134 }}
7135
7136 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
7137
7138 function loadAndRender(){{
7139 var root = rootSel.value;
7140 var sub = subSel ? subSel.value : '';
7141 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7142 document.getElementById('data-table-wrap').innerHTML='';
7143 var url = '/api/metrics/history?limit=100'
7144 + (root ? '&root='+encodeURIComponent(root) : '')
7145 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
7146 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7147 allData = data;
7148 render(data);
7149 updateStats(data);
7150 }}).catch(function(){{
7151 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>';
7152 }});
7153 }}
7154
7155 function render(data){{
7156 var yKey = document.getElementById('y-sel').value;
7157 var xMode = document.getElementById('x-sel').value;
7158
7159 // Filter for tag/release mode
7160 var pts = data;
7161 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7162
7163 // Sort oldest-first for the line chart
7164 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7165
7166 var wrap = document.getElementById('chart-wrap');
7167 if(!pts.length){{
7168 var emptyMsg = (xMode === 'tag')
7169 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7170 : 'No scan data found for the selected filters.';
7171 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7172 renderTable([]);
7173 return;
7174 }}
7175
7176 var scaleEl=document.getElementById('scale-sel');
7177 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7178 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;
7179 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7180
7181 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7182
7183 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">';
7184 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>';
7185
7186 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7187
7188 // Grid + Y axis ticks
7189 for(var ti=0;ti<=5;ti++){{
7190 var gy=PT+CH-Math.round(ti/5*CH);
7191 var gv=Math.round(ti/5*maxY);
7192 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7193 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7194 }}
7195
7196 // X axis labels (every N-th point to avoid crowding)
7197 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7198 pts.forEach(function(d,i){{
7199 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7200 if(i%labelEvery===0||i===pts.length-1){{
7201 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)));
7202 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>';
7203 }}
7204 }});
7205
7206 // Axis label
7207 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7208 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>';
7209 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>';
7210
7211 // Area fill + line path
7212 var pathD='';
7213 pts.forEach(function(d,i){{
7214 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7215 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7216 pathD+=(i===0?'M':'L')+x+','+y;
7217 }});
7218 if(pts.length>1){{
7219 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7220 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7221 }}
7222 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7223
7224 // Data points (clickable) + permanent value labels
7225 var showLabels = pts.length <= 40;
7226 var labelEveryN = pts.length > 20 ? 2 : 1;
7227 pts.forEach(function(d,i){{
7228 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7229 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7230 var hasTags=d.tags&&d.tags.length>0;
7231 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7232 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7233 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+'"/>';
7234 if(showLabels && i%labelEveryN===0){{
7235 var lx=x, ly=y-r-5;
7236 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>';
7237 }}
7238 }});
7239
7240 svg+='</svg>';
7241 wrap.innerHTML=svg;
7242
7243 // Attach point tooltips
7244 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7245 c.addEventListener('mouseover',function(e){{
7246 var d=pts[parseInt(this.dataset.idx)];
7247 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(''):'';
7248 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>':'';
7249 showTT(e,
7250 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7251 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7252 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7253 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7254 );
7255 this.setAttribute('r','8');
7256 }});
7257 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7258 c.addEventListener('mousemove',moveTT);
7259 c.addEventListener('click',function(){{
7260 var d=pts[parseInt(this.dataset.idx)];
7261 if(d.html_url) window.open(d.html_url,'_blank');
7262 }});
7263 }});
7264
7265 renderTable(pts, yKey);
7266 }}
7267
7268 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7269 var shProjFilter='', shBranchFilter='';
7270
7271 function fmtPST(isoStr){{
7272 if(!isoStr)return'';
7273 var d=new Date(isoStr);
7274 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7275 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);}}
7276 function p(n){{return n<10?'0'+n:String(n);}}
7277 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++;}}}}
7278 var yr=d.getUTCFullYear();
7279 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7280 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7281 var isDST=d>=dstStart&&d<dstEnd;
7282 var off=isDST?-7*3600*1000:-8*3600*1000;
7283 var lbl=isDST?'PDT':'PST';
7284 var loc=new Date(d.getTime()+off);
7285 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7286 }}
7287
7288 function getShRows(){{
7289 var proj=shProjFilter.toLowerCase().trim();
7290 var branch=shBranchFilter;
7291 return shData.filter(function(d){{
7292 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7293 if(branch&&(d.branch||'')!==branch)return false;
7294 return true;
7295 }});
7296 }}
7297
7298 function renderShPage(){{
7299 var filtered=getShRows();
7300 if(shSortCol){{
7301 filtered.sort(function(a,b){{
7302 var va,vb;
7303 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7304 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7305 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7306 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7307 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7308 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7309 }});
7310 }}
7311 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7312 shPage=Math.min(shPage,totalPages);
7313 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7314 var visible=filtered.slice(start,end);
7315 var tbody=document.getElementById('sh-tbody');
7316 if(!tbody)return;
7317 tbody.innerHTML=visible.map(function(d){{
7318 var tsHtml=esc(fmtPST(d.timestamp));
7319 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>';
7320 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>';
7321 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
7322 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
7323 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7324 var reportCell='';
7325 if(d.html_url){{
7326 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7327 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>';}}
7328 reportCell+='</div>';
7329 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
7330 if(d.submodule_links&&d.submodule_links.length){{
7331 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7332 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7333 reportCell+='</div></details>';
7334 }}
7335 return '<tr>'
7336 +'<td>'+tsHtml+'</td>'
7337 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7338 +'<td>'+runIdHtml+'</td>'
7339 +'<td>'+commitHtml+'</td>'
7340 +'<td>'+branchHtml+'</td>'
7341 +'<td>'+tags+'</td>'
7342 +'<td class="num">'+metricHtml+'</td>'
7343 +'<td class="report-cell">'+reportCell+'</td>'
7344 +'</tr>';
7345 }}).join('');
7346 var pgRange=document.getElementById('sh-pg-range');
7347 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
7348 var pgInfo=document.getElementById('sh-pg-info');
7349 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7350 var pgBtns=document.getElementById('sh-pg-btns');
7351 if(pgBtns){{
7352 pgBtns.innerHTML='';
7353 function mkPgBtn(lbl,pg,active,disabled){{
7354 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7355 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7356 return b;
7357 }}
7358 pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
7359 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7360 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7361 pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
7362 }}
7363 }}
7364
7365 function wireTableBehavior(){{
7366 var pf=document.getElementById('sh-proj-filter');
7367 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7368 var bf=document.getElementById('sh-branch-filter');
7369 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7370 var rb=document.getElementById('sh-reset-btn');
7371 if(rb)rb.addEventListener('click',function(){{
7372 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7373 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7374 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7375 document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
7376 renderShPage();
7377 }});
7378 var pps=document.getElementById('sh-per-page');
7379 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7380 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7381 ths.forEach(function(th){{
7382 th.addEventListener('click',function(e){{
7383 if(e.target.classList.contains('col-resize-handle'))return;
7384 var col=th.dataset.col;
7385 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7386 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
7387 th.classList.add('sort-'+shSortOrder);
7388 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
7389 shPage=1;renderShPage();
7390 }});
7391 }});
7392 var table=document.getElementById('scan-history-table');
7393 if(!table)return;
7394 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7395 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7396 allThs.forEach(function(th,i){{
7397 var handle=th.querySelector('.col-resize-handle');
7398 if(!handle||!cols[i])return;
7399 var startX,startW;
7400 handle.addEventListener('mousedown',function(e){{
7401 e.stopPropagation();e.preventDefault();
7402 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7403 handle.classList.add('dragging');
7404 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7405 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7406 document.addEventListener('mousemove',onMove);
7407 document.addEventListener('mouseup',onUp);
7408 }});
7409 }});
7410 }}
7411
7412 function renderTable(pts, yKey){{
7413 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7414 var wrap=document.getElementById('data-table-wrap');
7415 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7416 var yLabel=Y_LABELS[yKey]||yKey||'';
7417 shData=pts.slice().reverse();
7418 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7419 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7420 var branches={{}};
7421 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7422 var branchOpts='<option value="">All branches</option>';
7423 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7424 wrap.innerHTML=
7425 '<div class="chart-section-header">SCAN HISTORY</div>'+
7426 '<div class="filter-row">'+
7427 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
7428 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7429 '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
7430 '</div>'+
7431 '<div class="table-wrap">'+
7432 '<table id="scan-history-table" class="data-table">'+
7433 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7434 '<thead><tr id="sh-thead">'+
7435 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7436 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7437 '<th>Run ID<div class="col-resize-handle"></div></th>'+
7438 '<th>Commit<div class="col-resize-handle"></div></th>'+
7439 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7440 '<th>Tags<div class="col-resize-handle"></div></th>'+
7441 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7442 '<th>Report<div class="col-resize-handle"></div></th>'+
7443 '</tr></thead>'+
7444 '<tbody id="sh-tbody"></tbody>'+
7445 '</table>'+
7446 '</div>'+
7447 '<div class="pagination">'+
7448 '<span class="pagination-info" id="sh-pg-info"></span>'+
7449 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7450 '<div style="display:flex;align-items:center;gap:8px;">'+
7451 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7452 '<select class="filter-select" id="sh-per-page">'+
7453 '<option value="10">10 per page</option>'+
7454 '<option value="25" selected>25 per page</option>'+
7455 '<option value="50">50 per page</option>'+
7456 '<option value="100">100 per page</option>'+
7457 '</select>'+
7458 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7459 '</div>'+
7460 '</div>';
7461 wireTableBehavior();
7462 renderShPage();
7463 }}
7464
7465 function exportXLSX(){{
7466 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7467 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7468 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7469 var s1R=sorted.map(function(d){{
7470 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||''];
7471 }});
7472 var pm={{}};
7473 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7474 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'];
7475 var s2R=Object.keys(pm).map(function(p){{
7476 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7477 var lat=sc[sc.length-1],fst=sc[0];
7478 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7479 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);
7480 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];
7481 }});
7482 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7483 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7484 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7485 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7486 }}
7487
7488 function buildXLSX(sheets,chartRows,chartRows2){{
7489 function s2b(s){{return new TextEncoder().encode(s);}}
7490 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
7491 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;}}
7492 function crc32(d){{
7493 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;}}}}
7494 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7495 }}
7496 function buildSheet(hdr,rows,drawRid,withCtrl){{
7497 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7498 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7499 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7500 x+='<row r="1">';
7501 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7502 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>';}}
7503 x+='</row>';
7504 rows.forEach(function(row,ri){{
7505 var rn=ri+2;
7506 x+='<row r="'+rn+'">';
7507 row.forEach(function(cell,ci){{
7508 var addr=col2l(ci+1)+rn;
7509 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7510 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7511 }});
7512 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>';}}
7513 x+='</row>';
7514 }});
7515 x+='</sheetData>';
7516 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>';}}
7517 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7518 return x+'</worksheet>';
7519 }}
7520 function buildChartXML(rows){{
7521 var sn="'Scan History'";
7522 var nr=rows.length,er=nr+1;
7523 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'}}];
7524 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7525 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">';
7526 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7527 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7528 sd.forEach(function(s,i){{
7529 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7530 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>';
7531 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7532 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>';
7533 var dlp=(i===2)?'b':'t';
7534 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>';
7535 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7536 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7537 x+='</c:strCache></c:strRef></c:cat>';
7538 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+'"/>';
7539 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7540 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7541 }});
7542 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7543 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>';
7544 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>';
7545 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7546 return x;
7547 }}
7548 function buildChartXML2(rows){{
7549 var sn="'By Project'";
7550 var nr=rows.length,er=nr+1;
7551 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'}}];
7552 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7553 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">';
7554 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7555 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7556 sd.forEach(function(s,i){{
7557 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7558 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>';
7559 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7560 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>';
7561 var dlp=(i===2)?'b':'t';
7562 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>';
7563 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7564 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7565 x+='</c:strCache></c:strRef></c:cat>';
7566 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+'"/>';
7567 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7568 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7569 }});
7570 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7571 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>';
7572 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>';
7573 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7574 return x;
7575 }}
7576 function buildChartXML3(rows){{
7577 var sn="'Scan History'";
7578 var nr=rows.length,er=nr+1;
7579 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7580 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">';
7581 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7582 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7583 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7584 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>';
7585 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7586 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>';
7587 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>';
7588 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7589 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7590 x+='</c:strCache></c:strRef></c:cat>';
7591 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+'"/>';
7592 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7593 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7594 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7595 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>';
7596 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>';
7597 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>';
7598 return x;
7599 }}
7600 var hasChart=!!(chartRows&&chartRows.length);
7601 var nr=hasChart?chartRows.length:0;
7602 var hasChart2=!!(chartRows2&&chartRows2.length);
7603 var nr2=hasChart2?chartRows2.length:0;
7604 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>';
7605 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"/>';
7606 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7607 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"/>';}}
7608 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"/>';}}
7609 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7610 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>';
7611 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7612 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"/>';}});
7613 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7614 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>';
7615 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7616 wbx+='</sheets></workbook>';
7617 var files=[
7618 {{name:'[Content_Types].xml',data:s2b(ct)}},
7619 {{name:'_rels/.rels',data:s2b(dotrels)}},
7620 {{name:'xl/workbook.xml',data:s2b(wbx)}},
7621 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7622 {{name:'xl/styles.xml',data:s2b(styl)}}
7623 ];
7624 // Chart embedded directly in Scan History (sheet1); By Project is plain
7625 sheets.forEach(function(s,i){{
7626 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)))}});
7627 }});
7628 if(hasChart){{
7629 var fromRow=nr+4,toRow=nr+24;
7630 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>')}});
7631 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7632 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">';
7633 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7634 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>';
7635 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>';
7636 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7637 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7638 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7639 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7640 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7641 var focRow=toRow+2,focRowEnd=toRow+22;
7642 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7643 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>';
7644 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>';
7645 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7646 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7647 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7648 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7649 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7650 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7651 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>')}});
7652 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7653 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7654 }}
7655 if(hasChart2){{
7656 var fromRow2=nr2+4,toRow2=nr2+24;
7657 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>')}});
7658 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7659 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">';
7660 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7661 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>';
7662 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>';
7663 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7664 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7665 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7666 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7667 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7668 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7669 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>')}});
7670 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7671 }}
7672 var parts=[],offsets=[],total=0;
7673 files.forEach(function(f){{
7674 offsets.push(total);
7675 var nb=s2b(f.name),crc=crc32(f.data);
7676 var h=new DataView(new ArrayBuffer(30+nb.length));
7677 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7678 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7679 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7680 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7681 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7682 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7683 total+=30+nb.length+f.data.length;
7684 }});
7685 var cdStart=total;
7686 files.forEach(function(f,fi){{
7687 var nb=s2b(f.name),crc=crc32(f.data);
7688 var cd=new DataView(new ArrayBuffer(46+nb.length));
7689 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7690 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7691 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7692 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7693 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7694 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7695 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7696 }});
7697 var cdSz=total-cdStart;
7698 var eocd=new DataView(new ArrayBuffer(22));
7699 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7700 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7701 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7702 parts.push(new Uint8Array(eocd.buffer));
7703 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7704 var out=new Uint8Array(sz);var off=0;
7705 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7706 return out.buffer;
7707 }}
7708
7709 function exportPNG(){{
7710 var svgEl=document.querySelector('#chart-wrap svg');
7711 if(!svgEl){{alert('No chart to export yet.');return;}}
7712 var svgStr=new XMLSerializer().serializeToString(svgEl);
7713 var vb=svgEl.viewBox.baseVal,scale=2;
7714 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7715 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7716 var url=URL.createObjectURL(blob);
7717 var img=new Image();
7718 img.onload=function(){{
7719 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
7720 var ctx=canvas.getContext('2d');
7721 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
7722 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
7723 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
7724 URL.revokeObjectURL(url);
7725 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
7726 }};
7727 img.src=url;
7728 }}
7729
7730 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
7731 var el=document.getElementById(id);
7732 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
7733 }});
7734 rootSel.addEventListener('change',function(){{
7735 populateSubmodules(rootSel.value);
7736 loadAndRender();
7737 }});
7738 if(subSel)subSel.addEventListener('change',loadAndRender);
7739
7740 var xlsxBtn=document.getElementById('export-xlsx-btn');
7741 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
7742 var pngBtn=document.getElementById('export-png-btn');
7743 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
7744
7745 // ── Clean-up modal ───────────────────────────────────────────────────────
7746 (function(){{
7747 var triggerBtn=document.getElementById('cleanup-runs-btn');
7748 if(!triggerBtn)return;
7749 var modal=document.createElement('div');
7750 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;';
7751 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);">'
7752 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
7753 +'<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>'
7754 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
7755 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
7756 +'<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;">'
7757 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
7758 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
7759 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
7760 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
7761 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
7762 +'</div></div>';
7763 document.body.appendChild(modal);
7764 triggerBtn.addEventListener('click',function(){{
7765 document.getElementById('cleanup-status').style.display='none';
7766 modal.style.display='flex';
7767 }});
7768 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
7769 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
7770 document.getElementById('cleanup-confirm-btn').addEventListener('click',async function(){{
7771 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
7772 var confirmBtn=this;
7773 confirmBtn.disabled=true;
7774 var status=document.getElementById('cleanup-status');
7775 status.style.display='block';
7776 status.style.background='#dbeafe';status.style.color='#1e40af';
7777 status.textContent='Deleting…';
7778 try{{
7779 var resp=await fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}});
7780 var d=await resp.json();
7781 if(resp.ok){{
7782 status.style.background='#dcfce7';status.style.color='#166534';
7783 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing…';
7784 setTimeout(function(){{window.location.reload();}},1500);
7785 }}else{{
7786 status.style.background='#fee2e2';status.style.color='#991b1b';
7787 status.textContent='Error: '+(d.error||'Unexpected error');
7788 confirmBtn.disabled=false;
7789 }}
7790 }}catch(e){{
7791 status.style.background='#fee2e2';status.style.color='#991b1b';
7792 status.textContent='Network error: '+String(e);
7793 confirmBtn.disabled=false;
7794 }}
7795 }});
7796 }})();
7797
7798 populateSubmodules(rootSel.value);
7799 loadAndRender();
7800
7801 (function randomizeWatermarks() {{
7802 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7803 if (!wms.length) return;
7804 var placed = [];
7805 function tooClose(top, left) {{
7806 for (var i = 0; i < placed.length; i++) {{
7807 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
7808 if (dt < 16 && dl < 12) return true;
7809 }}
7810 return false;
7811 }}
7812 function pick(leftBand) {{
7813 for (var attempt = 0; attempt < 50; attempt++) {{
7814 var top = Math.random() * 88 + 2;
7815 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7816 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
7817 }}
7818 var top = Math.random() * 88 + 2;
7819 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7820 placed.push([top, left]); return [top, left];
7821 }}
7822 var half = Math.floor(wms.length / 2);
7823 wms.forEach(function (img, i) {{
7824 var pos = pick(i < half);
7825 var size = Math.floor(Math.random() * 100 + 120);
7826 var rot = (Math.random() * 360).toFixed(1);
7827 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
7828 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;
7829 }});
7830 }})();
7831 (function spawnCodeParticles() {{
7832 var container = document.getElementById('code-particles');
7833 if (!container) return;
7834 var snippets = [
7835 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
7836 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
7837 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
7838 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
7839 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
7840 ];
7841 var count = 38;
7842 for (var i = 0; i < count; i++) {{
7843 (function(idx) {{
7844 var el = document.createElement('span');
7845 el.className = 'code-particle';
7846 el.textContent = snippets[idx % snippets.length];
7847 var left = Math.random() * 94 + 2;
7848 var top = Math.random() * 88 + 6;
7849 var dur = (Math.random() * 10 + 9).toFixed(1);
7850 var delay = (Math.random() * 18).toFixed(1);
7851 var rot = (Math.random() * 26 - 13).toFixed(1);
7852 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7853 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
7854 container.appendChild(el);
7855 }})(i);
7856 }}
7857 }})();
7858 </script>
7859 <footer class="site-footer">
7860 local code analysis - metrics, history and reports
7861 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
7862 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7863 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7864 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7865 · <a href="/api-docs" rel="noopener">REST API</a>
7866 </footer>
7867 <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>
7868</body>
7869</html>"##,
7870 );
7871
7872 Html(html).into_response()
7873}
7874
7875fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7876 use std::collections::HashMap;
7877 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
7878 return vec![];
7879 }
7880 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7881 for rec in per_file_records {
7882 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7883 let e = totals.entry(lang.display_name().to_string()).or_default();
7884 e.0 += u64::from(cov.lines_found);
7885 e.1 += u64::from(cov.lines_hit);
7886 }
7887 }
7888 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
7890 .into_iter()
7891 .filter(|(_, (found, _))| *found > 0)
7892 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7893 .collect();
7894 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7895 pairs
7896 .iter()
7897 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
7898 .collect()
7899}
7900
7901fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
7902 let mut high = 0u64;
7903 let mut mid = 0u64;
7904 let mut low = 0u64;
7905 for rec in per_file_records {
7906 if let Some(cov) = &rec.coverage {
7907 if cov.lines_found == 0 {
7908 continue;
7909 }
7910 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7911 if pct >= 80.0 {
7912 high += 1;
7913 } else if pct >= 50.0 {
7914 mid += 1;
7915 } else {
7916 low += 1;
7917 }
7918 }
7919 }
7920 (high, mid, low)
7921}
7922
7923fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7924 let mut arr: Vec<serde_json::Value> = per_file_records
7925 .iter()
7926 .filter_map(|rec| {
7927 rec.coverage.as_ref().map(|cov| {
7928 let line_pct = if cov.lines_found > 0 {
7929 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
7930 / 10.0
7931 } else {
7932 0.0
7933 };
7934 let fn_pct = if cov.functions_found > 0 {
7935 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
7936 .round()
7937 / 10.0
7938 } else {
7939 -1.0
7940 };
7941 serde_json::json!({
7942 "rel": rec.relative_path,
7943 "lang": rec.language.map_or("?", |l| l.display_name()),
7944 "line_pct": line_pct,
7945 "fn_pct": fn_pct,
7946 "lhit": cov.lines_hit,
7947 "lfound": cov.lines_found,
7948 "fhit": cov.functions_hit,
7949 "ffound": cov.functions_found,
7950 })
7951 })
7952 })
7953 .collect();
7954 arr.sort_by(|a, b| {
7955 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7956 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7957 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7958 });
7959 arr
7960}
7961
7962#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
7964 let mut langs: Vec<&sloc_core::LanguageSummary> = run
7965 .totals_by_language
7966 .iter()
7967 .filter(|l| l.test_count > 0)
7968 .collect();
7969 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7970 let lang_tests: Vec<serde_json::Value> = langs
7971 .iter()
7972 .map(|l| {
7973 let d = if l.code_lines > 0 {
7974 l.test_count as f64 / l.code_lines as f64 * 1000.0
7975 } else {
7976 0.0
7977 };
7978 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
7979 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
7980 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
7981 })
7982 .collect();
7983 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
7984 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
7985 let t = &run.summary_totals;
7986 let total_tests = t.test_count;
7987 let density = if t.code_lines > 0 {
7988 total_tests as f64 / t.code_lines as f64 * 1000.0
7989 } else {
7990 0.0
7991 };
7992 let most_tested = langs.first().map_or_else(
7993 || "\u{2014}".to_string(),
7994 |l| l.language.display_name().to_string(),
7995 );
7996 let test_files: u64 = run
7997 .per_file_records
7998 .iter()
7999 .filter(|f| f.raw_line_categories.test_count > 0)
8000 .count() as u64;
8001 let cov_line = if t.coverage_lines_found > 0 {
8002 format!(
8003 "{:.1}",
8004 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8005 )
8006 } else {
8007 "0".to_string()
8008 };
8009 let cov_fn = if t.coverage_functions_found > 0 {
8010 format!(
8011 "{:.1}",
8012 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8013 )
8014 } else {
8015 "0".to_string()
8016 };
8017 let cov_branch = if t.coverage_branches_found > 0 {
8018 format!(
8019 "{:.1}",
8020 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8021 )
8022 } else {
8023 "0".to_string()
8024 };
8025 let has_cov = !cov_arr.is_empty();
8026 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8027 serde_json::json!({
8028 "totals": {
8029 "test_count": total_tests,
8030 "assertions": t.test_assertion_count,
8031 "suites": t.test_suite_count,
8032 "test_files": test_files,
8033 "total_files": t.files_analyzed,
8034 "density_str": format!("{density:.1}"),
8035 "most_tested": most_tested,
8036 "langs_with_tests": langs.len(),
8037 "cov_line": cov_line,
8038 "cov_fn": cov_fn,
8039 "cov_branch": cov_branch,
8040 },
8041 "lang_tests": lang_tests,
8042 "cov": cov_arr,
8043 "cov_tiers": {"high": high, "mid": mid, "low": low},
8044 "file_cov": file_cov_arr,
8045 "has_coverage": has_cov,
8046 "submodules": {},
8047 })
8048}
8049
8050#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8052 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8053 .language_summaries
8054 .iter()
8055 .filter(|l| l.test_count > 0)
8056 .collect();
8057 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8058 let lang_tests: Vec<serde_json::Value> = langs
8059 .iter()
8060 .map(|l| {
8061 let d = if l.code_lines > 0 {
8062 l.test_count as f64 / l.code_lines as f64 * 1000.0
8063 } else {
8064 0.0
8065 };
8066 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8067 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8068 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8069 })
8070 .collect();
8071 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8072 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8073 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8074 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8075 let density = if sub.code_lines > 0 {
8076 total_tests as f64 / sub.code_lines as f64 * 1000.0
8077 } else {
8078 0.0
8079 };
8080 let most_tested = langs.first().map_or_else(
8081 || "\u{2014}".to_string(),
8082 |l| l.language.display_name().to_string(),
8083 );
8084 serde_json::json!({
8085 "totals": {
8086 "test_count": total_tests,
8087 "assertions": total_assertions,
8088 "suites": total_suites,
8089 "test_files": test_files_approx,
8090 "total_files": sub.files_analyzed,
8091 "density_str": format!("{density:.1}"),
8092 "most_tested": most_tested,
8093 "langs_with_tests": langs.len(),
8094 "cov_line": "0",
8095 "cov_fn": "0",
8096 "cov_branch": "0",
8097 },
8098 "lang_tests": lang_tests,
8099 "cov": [],
8100 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8101 "has_coverage": false,
8102 })
8103}
8104
8105fn compute_cov_json_str(run: &AnalysisRun) -> String {
8106 use std::collections::HashMap;
8107 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8108 for rec in &run.per_file_records {
8109 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8110 let e = totals.entry(lang.display_name().to_string()).or_default();
8111 e.0 += u64::from(cov.lines_found);
8112 e.1 += u64::from(cov.lines_hit);
8113 }
8114 }
8115 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
8117 .into_iter()
8118 .filter(|(_, (found, _))| *found > 0)
8119 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8120 .collect();
8121 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8122 let parts: Vec<String> = pairs
8123 .iter()
8124 .map(|(lang, pct)| {
8125 let name = lang.replace('"', "\\\"");
8126 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8127 })
8128 .collect();
8129 format!("[{}]", parts.join(","))
8130}
8131
8132fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8133 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8134 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8135}
8136
8137fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8138 let mut entry = build_test_scope_entry(run);
8139 if !run.submodule_summaries.is_empty() {
8140 let subs: serde_json::Map<String, serde_json::Value> = run
8141 .submodule_summaries
8142 .iter()
8143 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8144 .collect();
8145 entry["submodules"] = serde_json::Value::Object(subs);
8146 }
8147 entry
8148}
8149
8150fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8151 let name = l.language.display_name().replace('"', "\\\"");
8152 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
8154 l.test_count as f64 / l.code_lines as f64 * 1000.0
8155 } else {
8156 0.0
8157 };
8158 format!(
8159 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8160 name = name,
8161 t = l.test_count,
8162 a = l.test_assertion_count,
8163 s = l.test_suite_count,
8164 c = l.code_lines,
8165 d = density,
8166 f = l.files,
8167 )
8168}
8169
8170fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8171 let Some(r) = run else {
8172 return "[]".to_string();
8173 };
8174 let mut langs: Vec<&sloc_core::LanguageSummary> = r
8175 .totals_by_language
8176 .iter()
8177 .filter(|l| l.test_count > 0)
8178 .collect();
8179 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8180 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8181 format!("[{}]", parts.join(","))
8182}
8183
8184#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
8188 State(state): State<AppState>,
8189 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8190) -> Response {
8191 auto_scan_watched_dirs(&state).await;
8192 let watched_dirs_list: Vec<String> = {
8193 let wd = state.watched_dirs.lock().await;
8194 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8195 };
8196 let latest_run: Option<AnalysisRun> = {
8197 let reg = state.registry.lock().await;
8198 let json_str: Option<String> = reg
8199 .entries
8200 .first()
8201 .and_then(|e| e.json_path.as_ref())
8202 .and_then(|p| std::fs::read_to_string(p).ok());
8203 drop(reg);
8204 json_str
8205 .as_deref()
8206 .and_then(|s| serde_json::from_str(s).ok())
8207 };
8208
8209 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8211
8212 let cov_json: String = latest_run
8214 .as_ref()
8215 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8216 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8217
8218 let _cov_tier_json: String = latest_run
8220 .as_ref()
8221 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8222 .map_or_else(
8223 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8224 compute_cov_tier_json_str,
8225 );
8226
8227 let total_tests: u64 = latest_run
8228 .as_ref()
8229 .map_or(0, |r| r.summary_totals.test_count);
8230 let total_assertions: u64 = latest_run
8231 .as_ref()
8232 .map_or(0, |r| r.summary_totals.test_assertion_count);
8233 let total_suites: u64 = latest_run
8234 .as_ref()
8235 .map_or(0, |r| r.summary_totals.test_suite_count);
8236 let total_code: u64 = latest_run
8237 .as_ref()
8238 .map_or(0, |r| r.summary_totals.code_lines);
8239 let workspace_density: f64 = if total_code > 0 {
8240 total_tests as f64 / total_code as f64 * 1000.0
8241 } else {
8242 0.0
8243 };
8244 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8245 r.totals_by_language
8246 .iter()
8247 .filter(|l| l.test_count > 0)
8248 .count()
8249 });
8250 let most_tested: String = latest_run
8251 .as_ref()
8252 .and_then(|r| {
8253 r.totals_by_language
8254 .iter()
8255 .filter(|l| l.test_count > 0)
8256 .max_by_key(|l| l.test_count)
8257 })
8258 .map_or_else(
8259 || "\u{2014}".to_string(),
8260 |l| l.language.display_name().to_string(),
8261 );
8262 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8263 r.per_file_records
8264 .iter()
8265 .filter(|f| f.raw_line_categories.test_count > 0)
8266 .count() as u64
8267 });
8268 let total_files_analyzed: u64 = latest_run
8269 .as_ref()
8270 .map_or(0, |r| r.summary_totals.files_analyzed);
8271 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8272
8273 let cov_line_pct_str: String = latest_run
8275 .as_ref()
8276 .filter(|r| r.summary_totals.coverage_lines_found > 0)
8277 .map_or_else(
8278 || "0".to_string(),
8279 |r| {
8280 format!(
8281 "{:.1}",
8282 r.summary_totals.coverage_lines_hit as f64
8283 / r.summary_totals.coverage_lines_found as f64
8284 * 100.0
8285 )
8286 },
8287 );
8288 let cov_fn_pct_str: String = latest_run
8289 .as_ref()
8290 .filter(|r| r.summary_totals.coverage_functions_found > 0)
8291 .map_or_else(
8292 || "0".to_string(),
8293 |r| {
8294 format!(
8295 "{:.1}",
8296 r.summary_totals.coverage_functions_hit as f64
8297 / r.summary_totals.coverage_functions_found as f64
8298 * 100.0
8299 )
8300 },
8301 );
8302 let cov_branch_pct_str: String = latest_run
8303 .as_ref()
8304 .filter(|r| r.summary_totals.coverage_branches_found > 0)
8305 .map_or_else(
8306 || "0".to_string(),
8307 |r| {
8308 format!(
8309 "{:.1}",
8310 r.summary_totals.coverage_branches_hit as f64
8311 / r.summary_totals.coverage_branches_found as f64
8312 * 100.0
8313 )
8314 },
8315 );
8316
8317 let cov_no_data_notice = if has_coverage {
8318 String::new()
8319 } else {
8320 String::from(
8321 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8322<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>
8323<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8324 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8325 <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>
8326 <span style="color:var(--muted);font-size:12px;">·</span>
8327 <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>
8328 <span style="color:var(--muted);font-size:12px;">·</span>
8329 <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>
8330</div>
8331<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8332</div>"#,
8333 )
8334 };
8335
8336 let workspace_density_str = format!("{workspace_density:.1}");
8337 let nonce = &csp_nonce;
8338 let version = env!("CARGO_PKG_VERSION");
8339
8340 let watched_dirs_html: String = if state.server_mode {
8343 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()
8344 } else {
8345 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8346 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8347 .to_string()
8348 } else {
8349 watched_dirs_list
8350 .iter()
8351 .fold(String::new(), |mut s, d| {
8352 use std::fmt::Write as _;
8353 let escaped =
8354 d.replace('&', "&").replace('"', """).replace('<', "<");
8355 write!(
8356 s,
8357 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>"#
8358 ).expect("write to String is infallible");
8359 s
8360 })
8361 };
8362 format!(
8363 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>"#
8364 )
8365 };
8366
8367 let scope_data_json: String = {
8369 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8370 scope_map.insert(
8371 "__all__".to_string(),
8372 latest_run.as_ref().map_or_else(
8373 || {
8374 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8375 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
8376 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8377 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8378 "has_coverage":false,"submodules":{}})
8379 },
8380 build_test_scope_entry,
8381 ),
8382 );
8383 let all_roots: Vec<String> = {
8384 let reg = state.registry.lock().await;
8385 let mut seen = std::collections::BTreeSet::new();
8386 reg.entries
8387 .iter()
8388 .flat_map(|e| e.input_roots.iter().cloned())
8389 .filter(|r| seen.insert(r.clone()))
8390 .collect()
8391 };
8392 for root in &all_roots {
8393 let run_for_root: Option<AnalysisRun> = {
8394 let reg = state.registry.lock().await;
8395 let json_str = reg
8396 .entries
8397 .iter()
8398 .find(|e| e.input_roots.iter().any(|r| r == root))
8399 .and_then(|e| e.json_path.as_ref())
8400 .and_then(|p| std::fs::read_to_string(p).ok());
8401 drop(reg);
8402 json_str
8403 .as_deref()
8404 .and_then(|s| serde_json::from_str(s).ok())
8405 };
8406 if let Some(ref run) = run_for_root {
8407 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8408 }
8409 }
8410 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8411 };
8412
8413 let html = format!(
8414 r#"<!doctype html>
8415<html lang="en">
8416<head>
8417 <meta charset="utf-8" />
8418 <meta name="viewport" content="width=device-width, initial-scale=1" />
8419 <title>OxideSLOC | Test Metrics</title>
8420 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8421 <style nonce="{nonce}">
8422 :root {{
8423 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8424 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8425 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8426 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8427 --info-bg:#eef3ff; --info-text:#4467d8;
8428 }}
8429 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8430 *{{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;}}
8431 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8432 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8433 .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;}}
8434 @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));}}}}
8435 .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);}}
8436 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8437 .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));}}
8438 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8439 .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;}}
8440 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8441 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8442 @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; }} }}
8443 .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;}}
8444 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8445 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8446 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8447 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8448 .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;}}
8449 .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;}}
8450 .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;}}
8451 .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;}}
8452 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8453 .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);}}
8454 .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;}}
8455 .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;}}
8456 .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;}}
8457 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8458 .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;}}
8459 .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);}}
8460 .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;}}
8461 .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;}}
8462 .tz-select:focus{{border-color:var(--oxide);}}
8463 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8464 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8465 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8466 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8467 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8468 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8469 .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;}}
8470 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8471 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8472 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8473 .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;}}
8474 .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;}}
8475 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8476 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8477 .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);}}
8478 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8479 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8480 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8481 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8482 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8483 .chart-canvas-wrap{{position:relative;height:280px;}}
8484 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8485 .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;}}
8486 .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;}}
8487 .data-table tr:last-child td{{border-bottom:none;}}
8488 .data-table tbody tr:hover td{{background:var(--surface-2);}}
8489 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8490 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8491 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8492 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8493 .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;}}
8494 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8495 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8496 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8497 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8498 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8499 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8500 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8501 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8502 .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;}}
8503 .chart-select:focus{{border-color:var(--accent);}}
8504 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8505 .trend-canvas-wrap{{position:relative;height:260px;}}
8506 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8507 .site-footer a{{color:var(--muted);}}
8508 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8509 .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;}}
8510 .btn:hover{{background:var(--surface-2);}}
8511 .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;}}
8512 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8513 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8514 .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;}}
8515 .scope-sel:focus{{border-color:var(--accent);}}
8516 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8517 .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;}}
8518 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8519 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8520 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8521 .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;}}
8522 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8523 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8524 .watched-chip-rm:hover{{color:var(--oxide);}}
8525 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8526 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8527 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8528 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8529 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8530 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8531 .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;}}
8532 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8533 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8534 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8535 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8536 .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;}}
8537 .cov-file-search:focus{{border-color:var(--accent);}}
8538 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8539 .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;}}
8540 body.dark-theme .cov-file-search{{background:var(--surface);}}
8541 </style>
8542</head>
8543<body>
8544 <div class="background-watermarks" aria-hidden="true">
8545 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8546 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8547 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8548 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8549 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8550 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8551 </div>
8552 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8553 <div class="top-nav">
8554 <div class="top-nav-inner">
8555 <a class="brand" href="/">
8556 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8557 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8558 </a>
8559 <div class="nav-right">
8560 <a class="nav-pill" href="/">Home</a>
8561 <div class="nav-dropdown">
8562 <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>
8563 <div class="nav-dropdown-menu">
8564 <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>
8565 </div>
8566 </div>
8567 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8568 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8569 <div class="nav-dropdown">
8570 <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>
8571 <div class="nav-dropdown-menu">
8572 <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>
8573 </div>
8574 </div>
8575 <div class="server-status-wrap" id="server-status-wrap">
8576 <div class="nav-pill server-online-pill" id="server-status-pill">
8577 <span class="status-dot" id="status-dot"></span>
8578 <span id="server-status-label">Server</span>
8579 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8580 </div>
8581 <div class="server-status-tip">
8582 OxideSLOC is running — accessible on your network.
8583 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8584 </div>
8585 </div>
8586 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8587 <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>
8588 </button>
8589 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8590 <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>
8591 <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>
8592 </button>
8593 </div>
8594 </div>
8595 </div>
8596
8597 <div class="page">
8598 {watched_dirs_html}
8599 <div class="scope-bar">
8600 <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>
8601 <span class="scope-label">Scope</span>
8602 <div class="scope-sel-wrap">
8603 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8604 <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);">
8605 <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>
8606 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8607 </div>
8608 </div>
8609 </div>
8610 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8611 <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>
8612 <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>
8613 <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>
8614 <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>
8615 </div>
8616 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8617 <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>
8618 <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>
8619 <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>
8620 <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>
8621 </div>
8622
8623 <div class="panel">
8624 <h1>Test Metrics</h1>
8625 <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>
8626
8627 <div class="chart-row">
8628 <div class="chart-box">
8629 <div class="chart-box-title">Test Definitions by Language</div>
8630 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8631 </div>
8632 <div class="chart-box">
8633 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8634 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8635 </div>
8636 </div>
8637
8638 <div class="section-header">Language Breakdown</div>
8639 {cov_no_data_notice}
8640 <div style="overflow-x:auto;">
8641 <table class="data-table" id="lang-table">
8642 <thead><tr>
8643 <th>Language</th>
8644 <th class="num">Test Fns</th>
8645 <th class="num">Assertions</th>
8646 <th class="num">Suites</th>
8647 <th class="num">Code Lines</th>
8648 <th class="num">Files</th>
8649 <th class="num">Density / 1K</th>
8650 <th>Relative Density</th>
8651 </tr></thead>
8652 <tbody id="lang-tbody"></tbody>
8653 </table>
8654 </div>
8655 </div>
8656
8657 <div class="panel" id="cov-panel" style="display:none;">
8658 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8659 <div class="cov-gauge-row" id="cov-gauges">
8660 <div class="cov-gauge-card">
8661 <div class="cov-gauge-label">Line Coverage</div>
8662 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8663 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8664 <div class="cov-gauge-sub">Lines hit / instrumented</div>
8665 </div>
8666 <div class="cov-gauge-card">
8667 <div class="cov-gauge-label">Function Coverage</div>
8668 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8669 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8670 <div class="cov-gauge-sub">Functions hit / found</div>
8671 </div>
8672 <div class="cov-gauge-card">
8673 <div class="cov-gauge-label">Branch Coverage</div>
8674 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8675 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8676 <div class="cov-gauge-sub">Branches hit / found</div>
8677 </div>
8678 </div>
8679 <div class="chart-row">
8680 <div class="chart-box">
8681 <div class="chart-box-title">Line Coverage % by Language</div>
8682 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8683 </div>
8684 <div class="chart-box">
8685 <div class="chart-box-title">Coverage Tier Distribution</div>
8686 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8687 </div>
8688 </div>
8689
8690 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8691 <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>
8692 <div class="cov-file-toolbar">
8693 <div class="cov-filter-tabs" id="cov-filter-tabs">
8694 <button class="cov-tab active" data-tier="all">All</button>
8695 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8696 <button class="cov-tab" data-tier="low">Low (<50%)</button>
8697 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8698 <button class="cov-tab" data-tier="high">High (≥80%)</button>
8699 </div>
8700 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8701 </div>
8702 <div style="overflow-x:auto;">
8703 <table class="data-table" id="cov-file-table">
8704 <thead><tr>
8705 <th>File</th>
8706 <th>Lang</th>
8707 <th class="num">Line %</th>
8708 <th class="num">Lines Hit / Found</th>
8709 <th class="num">Fn %</th>
8710 <th class="num">Fns Hit / Found</th>
8711 </tr></thead>
8712 <tbody id="cov-file-tbody"></tbody>
8713 </table>
8714 </div>
8715 <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>
8716 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
8717 </div>
8718
8719 <div class="panel">
8720 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
8721 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
8722 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
8723 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
8724 </div>
8725 </div>
8726
8727 <footer class="site-footer">
8728 local code analysis - metrics, history and reports
8729 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
8730 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8731 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8732 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8733 · <a href="/api-docs" rel="noopener">REST API</a>
8734 </footer>
8735
8736 <script nonce="{nonce}">
8737 (function() {{
8738 // Theme
8739 var b = document.body;
8740 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8741 var tgl = document.getElementById('theme-toggle');
8742 if (tgl) tgl.addEventListener('click', function() {{
8743 var d = b.classList.toggle('dark-theme');
8744 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8745 }});
8746
8747 // Watermarks
8748 (function() {{
8749 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8750 if (!wms.length) return;
8751 var placed = [];
8752 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;}}
8753 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];}}
8754 var half=Math.floor(wms.length/2);
8755 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;}});
8756 }})();
8757
8758 // Code particles
8759 (function() {{
8760 var container = document.getElementById('code-particles');
8761 if (!container) return;
8762 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
8763 for (var i = 0; i < 36; i++) {{
8764 (function(idx) {{
8765 var el = document.createElement('span');
8766 el.className = 'code-particle';
8767 el.textContent = snippets[idx % snippets.length];
8768 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8769 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8770 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8771 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';
8772 container.appendChild(el);
8773 }})(i);
8774 }}
8775 }})();
8776
8777 // Settings modal
8778 (function() {{
8779 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'}}];
8780 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);}});}}
8781 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8782 var btn=document.getElementById('settings-btn');if(!btn)return;
8783 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8784 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>';
8785 document.body.appendChild(m);
8786 var g=document.getElementById('scheme-grid');
8787 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);}});
8788 var cl=document.getElementById('settings-close');
8789 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');}});
8790 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8791 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8792 }})();
8793
8794 // Watched folder picker
8795 (function() {{
8796 var btn = document.getElementById('add-watched-btn');
8797 if (!btn) return;
8798 btn.addEventListener('click', function() {{
8799 fetch('/pick-directory?kind=reports')
8800 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8801 .then(function(data) {{
8802 if (!data.cancelled && data.selected_path) {{
8803 var form = document.createElement('form');
8804 form.method = 'POST';
8805 form.action = '/watched-dirs/add';
8806 var ri = document.createElement('input');
8807 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8808 var fi = document.createElement('input');
8809 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8810 form.appendChild(ri); form.appendChild(fi);
8811 document.body.appendChild(form);
8812 form.submit();
8813 }}
8814 }})
8815 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8816 }});
8817 }})();
8818 }})();
8819 </script>
8820
8821 <script src="/static/chart.js" nonce="{nonce}"></script>
8822 <script nonce="{nonce}">
8823 (function() {{
8824 var SCOPE_DATA = {scope_data_json};
8825 var currentRoot = '__all__';
8826 var currentSub = '';
8827 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
8828 var ALL_CHARTS = [];
8829
8830 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();}}
8831 function fmtFull(n){{return Number(n).toLocaleString();}}
8832 function isDark(){{return document.body.classList.contains('dark-theme');}}
8833 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
8834 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
8835 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
8836
8837 function getDataset() {{
8838 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8839 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
8840 return r;
8841 }}
8842 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
8843
8844 function renderTestCharts(D) {{
8845 testsChart = destroyChart(testsChart);
8846 densityChart = destroyChart(densityChart);
8847 if (!D || !D.length) return;
8848 var top15 = D.slice(0, 15);
8849 var canvas1 = document.getElementById('canvas-tests');
8850 if (canvas1) {{
8851 testsChart = new Chart(canvas1, {{
8852 type: 'bar',
8853 data: {{
8854 labels: top15.map(function(d){{ return d.lang; }}),
8855 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
8856 }},
8857 options: {{
8858 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8859 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
8860 scales: {{
8861 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
8862 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8863 }}
8864 }}
8865 }});
8866 ALL_CHARTS.push(testsChart);
8867 }}
8868 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
8869 var canvas2 = document.getElementById('canvas-density');
8870 if (canvas2) {{
8871 densityChart = new Chart(canvas2, {{
8872 type: 'bar',
8873 data: {{
8874 labels: topD.map(function(d){{ return d.lang; }}),
8875 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 }}]
8876 }},
8877 options: {{
8878 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8879 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
8880 scales: {{
8881 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
8882 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8883 }}
8884 }}
8885 }});
8886 ALL_CHARTS.push(densityChart);
8887 }}
8888 }}
8889
8890 function renderCovCharts(covD, tiers) {{
8891 covChart = destroyChart(covChart);
8892 tierChart = destroyChart(tierChart);
8893 var covCanvas = document.getElementById('canvas-cov');
8894 if (covCanvas && covD && covD.length) {{
8895 covChart = new Chart(covCanvas, {{
8896 type: 'bar',
8897 data: {{
8898 labels: covD.map(function(d){{ return d.lang; }}),
8899 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 }}]
8900 }},
8901 options: {{
8902 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8903 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
8904 scales: {{
8905 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
8906 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8907 }}
8908 }}
8909 }});
8910 ALL_CHARTS.push(covChart);
8911 }}
8912 var tierCanvas = document.getElementById('canvas-cov-tiers');
8913 if (tierCanvas && tiers) {{
8914 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
8915 tierChart = new Chart(tierCanvas, {{
8916 type: 'doughnut',
8917 data: {{
8918 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
8919 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
8920 }},
8921 options: {{
8922 responsive: true, maintainAspectRatio: false, cutout: '62%',
8923 plugins: {{
8924 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
8925 tooltip: {{ callbacks: {{ label: function(ctx) {{
8926 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
8927 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
8928 }} }} }}
8929 }}
8930 }}
8931 }});
8932 ALL_CHARTS.push(tierChart);
8933 }}
8934 }}
8935
8936 function buildLangTable(D) {{
8937 var tbody = document.getElementById('lang-tbody');
8938 if (!tbody) return;
8939 if (!D || !D.length) {{
8940 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>';
8941 return;
8942 }}
8943 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
8944 tbody.innerHTML = D.map(function(d) {{
8945 var barW = Math.round(d.density / maxDensity * 120);
8946 return '<tr>' +
8947 '<td><strong>' + d.lang + '</strong></td>' +
8948 '<td class="num">' + fmt(d.tests) + '</td>' +
8949 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
8950 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
8951 '<td class="num">' + fmt(d.code) + '</td>' +
8952 '<td class="num">' + fmt(d.files) + '</td>' +
8953 '<td class="num">' + d.density.toFixed(2) + '</td>' +
8954 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
8955 '</tr>';
8956 }}).join('');
8957 }}
8958
8959 var covFileData = [];
8960 var covFileTier = 'all';
8961 var covFileSearch = '';
8962
8963 function pctBadge(pct) {{
8964 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
8965 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
8966 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
8967 }}
8968
8969 function buildCovFileTable() {{
8970 var tbody = document.getElementById('cov-file-tbody');
8971 var empty = document.getElementById('cov-file-empty');
8972 var count = document.getElementById('cov-file-count');
8973 if (!tbody) return;
8974 var srch = covFileSearch.toLowerCase();
8975 var filtered = covFileData.filter(function(f) {{
8976 if (covFileTier === 'zero' && f.line_pct > 0) return false;
8977 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
8978 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
8979 if (covFileTier === 'high' && f.line_pct < 80) return false;
8980 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
8981 return true;
8982 }});
8983 if (!filtered.length) {{
8984 tbody.innerHTML = '';
8985 if (empty) empty.style.display = '';
8986 if (count) count.textContent = '';
8987 return;
8988 }}
8989 if (empty) empty.style.display = 'none';
8990 var shown = Math.min(filtered.length, 500);
8991 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
8992 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
8993 var fnCol = f.fn_pct < 0
8994 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
8995 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
8996 return '<tr>' +
8997 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
8998 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
8999 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9000 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9001 fnCol +
9002 '</tr>';
9003 }}).join('');
9004 }}
9005
9006 (function() {{
9007 var tabs = document.getElementById('cov-filter-tabs');
9008 if (tabs) {{
9009 tabs.addEventListener('click', function(e) {{
9010 var btn = e.target.closest('.cov-tab');
9011 if (!btn) return;
9012 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9013 btn.classList.add('active');
9014 covFileTier = btn.getAttribute('data-tier');
9015 buildCovFileTable();
9016 }});
9017 }}
9018 var srch = document.getElementById('cov-file-search');
9019 if (srch) {{
9020 srch.addEventListener('input', function() {{
9021 covFileSearch = this.value;
9022 buildCovFileTable();
9023 }});
9024 }}
9025 }})();
9026
9027 function updateCovGauges(t) {{
9028 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9029 var el;
9030 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9031 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9032 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9033 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9034 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9035 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9036 }}
9037
9038 function applyScope() {{
9039 var d = getDataset();
9040 var t = d.totals;
9041 var el;
9042 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9043 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9044 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9045 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9046 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9047 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9048 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9049 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9050 renderTestCharts(d.lang_tests);
9051 buildLangTable(d.lang_tests);
9052 var covPanel = document.getElementById('cov-panel');
9053 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9054 if (d.has_coverage) {{
9055 renderCovCharts(d.cov, d.cov_tiers);
9056 updateCovGauges(t);
9057 covFileData = d.file_cov || [];
9058 covFileTier = 'all';
9059 covFileSearch = '';
9060 var tabs = document.getElementById('cov-filter-tabs');
9061 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9062 var srch = document.getElementById('cov-file-search');
9063 if (srch) srch.value = '';
9064 buildCovFileTable();
9065 }}
9066 loadTrend();
9067 }}
9068
9069 // Populate scope-root-sel from SCOPE_DATA keys
9070 (function() {{
9071 var sel = document.getElementById('scope-root-sel');
9072 if (!sel) return;
9073 Object.keys(SCOPE_DATA).forEach(function(k) {{
9074 if (k === '__all__') return;
9075 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9076 }});
9077 }})();
9078
9079 document.getElementById('scope-root-sel').addEventListener('change', function() {{
9080 currentRoot = this.value;
9081 currentSub = '';
9082 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9083 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9084 var subWrap = document.getElementById('scope-sub-wrap');
9085 var subSel = document.getElementById('scope-sub-sel');
9086 subSel.innerHTML = '<option value="">Entire project</option>';
9087 if (subNames.length) {{
9088 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9089 subWrap.style.display = 'flex';
9090 }} else {{
9091 subWrap.style.display = 'none';
9092 }}
9093 applyScope();
9094 }});
9095
9096 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9097 currentSub = this.value;
9098 applyScope();
9099 }});
9100
9101 function buildTrend(data) {{
9102 var trendCanvas = document.getElementById('canvas-trend');
9103 var trendEmpty = document.getElementById('trend-empty');
9104 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9105 pts = pts.slice().reverse();
9106 if (!pts.length) {{
9107 if (trendCanvas) trendCanvas.style.display = 'none';
9108 if (trendEmpty) trendEmpty.style.display = '';
9109 return;
9110 }}
9111 if (trendCanvas) trendCanvas.style.display = '';
9112 if (trendEmpty) trendEmpty.style.display = 'none';
9113 trendChart = destroyChart(trendChart);
9114 if (!trendCanvas) return;
9115 trendChart = new Chart(trendCanvas, {{
9116 type: 'line',
9117 data: {{
9118 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9119 datasets: [{{
9120 label: 'Test Definitions',
9121 data: pts.map(function(d){{ return d.test_count; }}),
9122 borderColor: '#C45C10',
9123 backgroundColor: 'rgba(196,92,16,0.10)',
9124 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9125 pointRadius: 5, fill: true, tension: 0.3
9126 }}]
9127 }},
9128 options: {{
9129 responsive: true, maintainAspectRatio: false,
9130 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9131 scales: {{
9132 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9133 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9134 }}
9135 }}
9136 }});
9137 ALL_CHARTS.push(trendChart);
9138 }}
9139
9140 function loadTrend() {{
9141 var url = '/api/metrics/history?limit=100';
9142 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9143 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9144 buildTrend(data);
9145 }}).catch(function(){{
9146 var trendEmpty = document.getElementById('trend-empty');
9147 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9148 }});
9149 }}
9150
9151 // Re-render charts on theme toggle
9152 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9153 setTimeout(function() {{
9154 ALL_CHARTS.forEach(function(c) {{
9155 if (c && c.options && c.options.scales) {{
9156 Object.values(c.options.scales).forEach(function(ax) {{
9157 if (ax.grid) ax.grid.color = clr();
9158 if (ax.ticks) ax.ticks.color = txtClr();
9159 }});
9160 c.update();
9161 }}
9162 }});
9163 }}, 80);
9164 }});
9165
9166 applyScope();
9167 }})();
9168 </script>
9169 <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>
9170</body>
9171</html>"#,
9172 );
9173 Html(html).into_response()
9174}
9175
9176#[derive(Deserialize)]
9183struct EmbedQuery {
9184 run_id: Option<String>,
9185 theme: Option<String>,
9186}
9187
9188async fn embed_handler(
9189 State(state): State<AppState>,
9190 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9191 Query(query): Query<EmbedQuery>,
9192) -> Response {
9193 let entry = {
9194 let reg = state.registry.lock().await;
9195 query.run_id.as_ref().map_or_else(
9196 || reg.entries.first().cloned(),
9197 |id| reg.find_by_run_id(id).cloned(),
9198 )
9199 };
9200
9201 let Some(entry) = entry else {
9202 return Html(
9203 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9204 .to_string(),
9205 )
9206 .into_response();
9207 };
9208
9209 let dark = query.theme.as_deref() == Some("dark");
9210 let languages: Vec<(String, u64, u64)> = entry
9211 .json_path
9212 .as_ref()
9213 .and_then(|p| read_json(p).ok())
9214 .map(|run| {
9215 run.totals_by_language
9216 .iter()
9217 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9218 .collect()
9219 })
9220 .unwrap_or_default();
9221
9222 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9223}
9224
9225fn render_embed_widget(
9226 entry: &RegistryEntry,
9227 languages: &[(String, u64, u64)],
9228 dark: bool,
9229 csp_nonce: &str,
9230) -> String {
9231 let s = &entry.summary;
9232 let total = s.code_lines + s.comment_lines + s.blank_lines;
9233 let code_pct = s
9234 .code_lines
9235 .checked_mul(100)
9236 .and_then(|n| n.checked_div(total))
9237 .unwrap_or(0);
9238
9239 let (bg, fg, surface, muted, border) = if dark {
9240 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9241 } else {
9242 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9243 };
9244
9245 let mut lang_rows = String::new();
9246 for (name, files, code) in languages {
9247 write!(
9248 lang_rows,
9249 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9250 escape_html(name),
9251 format_number(*files),
9252 format_number(*code),
9253 )
9254 .ok();
9255 }
9256
9257 let lang_table = if lang_rows.is_empty() {
9258 String::new()
9259 } else {
9260 format!(
9261 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9262 )
9263 };
9264
9265 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9266 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9267 let project_esc = escape_html(&entry.project_label);
9268 let code_lines = format_number(s.code_lines);
9269 let comment_lines = format_number(s.comment_lines);
9270 let files = format_number(s.files_analyzed);
9271 let code_raw = s.code_lines;
9272 let comment_raw = s.comment_lines;
9273 let blank_raw = s.blank_lines;
9274
9275 format!(
9276 r#"<!doctype html>
9277<html lang="en">
9278<head>
9279 <meta charset="utf-8">
9280 <meta name="viewport" content="width=device-width,initial-scale=1">
9281 <title>OxideSLOC — {project_esc}</title>
9282 <script src="/static/chart.js"></script>
9283 <style nonce="{csp_nonce}">
9284 *{{box-sizing:border-box;margin:0;padding:0}}
9285 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9286 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9287 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9288 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9289 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9290 .card .v{{font-size:18px;font-weight:700}}
9291 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9292 .row{{display:flex;gap:12px;align-items:flex-start}}
9293 .pie{{width:120px;height:120px;flex-shrink:0}}
9294 .lt{{border-collapse:collapse;width:100%;flex:1}}
9295 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9296 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9297 .n{{text-align:right}}
9298 .footer{{margin-top:10px;color:{muted};font-size:10px}}
9299 </style>
9300</head>
9301<body>
9302 <h2>{project_esc}</h2>
9303 <div class="sub">{timestamp} · run {run_short}</div>
9304 <div class="cards">
9305 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9306 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9307 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9308 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9309 </div>
9310 <div class="row">
9311 <canvas class="pie" id="c"></canvas>
9312 {lang_table}
9313 </div>
9314 <div class="footer">oxide-sloc</div>
9315 <script nonce="{csp_nonce}">
9316 new Chart(document.getElementById('c'),{{
9317 type:'doughnut',
9318 data:{{
9319 labels:['Code','Comments','Blank'],
9320 datasets:[{{
9321 data:[{code_raw},{comment_raw},{blank_raw}],
9322 backgroundColor:['#4a78ee','#b35428','#aaa'],
9323 borderWidth:0
9324 }}]
9325 }},
9326 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9327 }});
9328 </script>
9329</body>
9330</html>"#
9331 )
9332}
9333
9334#[allow(clippy::too_many_arguments)]
9335fn persist_run_artifacts(
9336 run: &sloc_core::AnalysisRun,
9337 report_html: &str,
9338 run_dir: &Path,
9339 generate_json: bool,
9340 generate_html: bool,
9341 generate_pdf: bool,
9342 report_title: &str,
9343 file_stem: &str,
9344 result_context: RunResultContext,
9345) -> Result<(RunArtifacts, PendingPdf)> {
9346 fs::create_dir_all(run_dir)
9347 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9348
9349 let mut html_path = None;
9350 let mut pdf_path = None;
9351 let mut json_path = None;
9352 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9353
9354 if generate_html {
9355 let path = run_dir.join(format!("report_{file_stem}.html"));
9356 fs::write(&path, report_html)
9357 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9358 html_path = Some(path);
9359 }
9360
9361 if generate_json {
9362 let path = run_dir.join(format!("result_{file_stem}.json"));
9363 let json = serde_json::to_string_pretty(run)
9364 .context("failed to serialize analysis run to JSON")?;
9365 fs::write(&path, json)
9366 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9367 json_path = Some(path);
9368 }
9369
9370 if generate_pdf {
9371 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9372
9373 match write_pdf_from_run(run, &pdf_dest) {
9376 Ok(()) => {
9377 eprintln!(
9378 "[oxide-sloc][pdf] native PDF written to {}",
9379 pdf_dest.display()
9380 );
9381 pdf_path = Some(pdf_dest);
9382 }
9384 Err(native_err) => {
9385 eprintln!(
9386 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9387 scheduling HTML→browser fallback"
9388 );
9389 let source_html_path = if let Some(existing) = html_path.as_ref() {
9390 existing.clone()
9391 } else {
9392 let temp_html = run_dir.join("_report_rendered.html");
9393 fs::write(&temp_html, report_html).with_context(|| {
9394 format!(
9395 "failed to write temporary HTML report to {}",
9396 temp_html.display()
9397 )
9398 })?;
9399 temp_html
9400 };
9401 let cleanup_src = !generate_html;
9402 pdf_path = Some(pdf_dest.clone());
9403 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9404 }
9405 }
9406 }
9407
9408 let csv_path = {
9410 let path = run_dir.join(format!("report_{file_stem}.csv"));
9411 if let Err(e) = sloc_report::write_csv(run, &path) {
9412 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9413 None
9414 } else {
9415 Some(path)
9416 }
9417 };
9418
9419 let xlsx_path = {
9420 let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9421 if let Err(e) = sloc_report::write_xlsx(run, &path) {
9422 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9423 None
9424 } else {
9425 Some(path)
9426 }
9427 };
9428
9429 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9430
9431 Ok((
9432 RunArtifacts {
9433 output_dir: run_dir.to_path_buf(),
9434 html_path,
9435 pdf_path,
9436 json_path,
9437 csv_path,
9438 xlsx_path,
9439 scan_config_path,
9440 report_title: report_title.to_string(),
9441 result_context,
9442 },
9443 pending_pdf,
9444 ))
9445}
9446
9447fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9450 let exact = dir.join("scan-config.json");
9451 if exact.exists() {
9452 return Some(exact);
9453 }
9454 fs::read_dir(dir).ok().and_then(|entries| {
9455 entries
9456 .filter_map(std::result::Result::ok)
9457 .find(|e| {
9458 let name = e.file_name();
9459 let name = name.to_string_lossy();
9460 name.starts_with("scan-config") && name.ends_with(".json")
9461 })
9462 .map(|e| e.path())
9463 })
9464}
9465
9466async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9469 let toml_str = match toml::to_string_pretty(&state.base_config) {
9470 Ok(s) => s,
9471 Err(e) => {
9472 return (
9473 StatusCode::INTERNAL_SERVER_ERROR,
9474 format!("serialization error: {e}"),
9475 )
9476 .into_response();
9477 }
9478 };
9479 (
9480 [
9481 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9482 (
9483 header::CONTENT_DISPOSITION,
9484 "attachment; filename=\".oxide-sloc.toml\"",
9485 ),
9486 ],
9487 toml_str,
9488 )
9489 .into_response()
9490}
9491
9492#[derive(Serialize)]
9493struct OkResponse {
9494 ok: bool,
9495}
9496
9497#[derive(Serialize)]
9498struct SaveProfileResponse {
9499 ok: bool,
9500 id: String,
9501}
9502
9503#[derive(Serialize)]
9504struct ProfileListResponse {
9505 profiles: Vec<ScanProfile>,
9506}
9507
9508#[derive(Serialize)]
9509struct ImportConfigResponse {
9510 ok: bool,
9511 config: sloc_config::AppConfig,
9512}
9513
9514#[derive(Deserialize)]
9515struct ImportConfigBody {
9516 toml: String,
9517}
9518
9519async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9520 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9521 Ok(config) => {
9522 if let Err(e) = config.validate() {
9523 return error::unprocessable_entity(&e.to_string());
9524 }
9525 Json(ImportConfigResponse { ok: true, config }).into_response()
9526 }
9527 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9528 }
9529}
9530
9531async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9534 let store = state.scan_profiles.lock().await;
9535 Json(ProfileListResponse {
9536 profiles: store.profiles.clone(),
9537 })
9538}
9539
9540#[derive(Deserialize)]
9541struct SaveScanProfileBody {
9542 name: String,
9543 params: serde_json::Value,
9544}
9545
9546async fn api_save_scan_profile(
9547 State(state): State<AppState>,
9548 Json(body): Json<SaveScanProfileBody>,
9549) -> impl IntoResponse {
9550 if body.name.trim().is_empty() {
9551 return error::bad_request("name must not be empty");
9552 }
9553
9554 let id = uuid::Uuid::new_v4().to_string();
9555 let profile = ScanProfile {
9556 id: id.clone(),
9557 name: body.name.trim().to_string(),
9558 created_at: chrono::Utc::now().to_rfc3339(),
9559 params: body.params,
9560 };
9561
9562 let mut store = state.scan_profiles.lock().await;
9563 store.profiles.push(profile);
9564 if let Err(e) = store.save(&state.scan_profiles_path) {
9565 tracing::warn!("failed to persist scan profiles: {e}");
9566 }
9567 drop(store);
9568
9569 (
9570 StatusCode::CREATED,
9571 Json(SaveProfileResponse { ok: true, id }),
9572 )
9573 .into_response()
9574}
9575
9576async fn api_delete_scan_profile(
9577 State(state): State<AppState>,
9578 AxumPath(id): AxumPath<String>,
9579) -> impl IntoResponse {
9580 let mut store = state.scan_profiles.lock().await;
9581 let before = store.profiles.len();
9582 store.profiles.retain(|p| p.id != id);
9583 if store.profiles.len() == before {
9584 drop(store);
9585 return error::not_found("profile not found");
9586 }
9587 if let Err(e) = store.save(&state.scan_profiles_path) {
9588 tracing::warn!("failed to persist scan profiles: {e}");
9589 }
9590 drop(store);
9591 Json(OkResponse { ok: true }).into_response()
9592}
9593
9594fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9595 let value = raw.unwrap_or("out/web").trim();
9596 let path = if value.is_empty() {
9597 PathBuf::from("out/web")
9598 } else {
9599 PathBuf::from(value)
9600 };
9601
9602 if path.is_absolute() {
9603 path
9604 } else {
9605 workspace_root().join(path)
9606 }
9607}
9608
9609fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9611 std::env::var("SLOC_GIT_CLONES_DIR")
9612 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9613}
9614
9615pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9618 let safe: String = repo_url
9619 .chars()
9620 .map(|c| {
9621 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9622 c
9623 } else {
9624 '_'
9625 }
9626 })
9627 .take(80)
9628 .collect();
9629 clones_dir.join(safe)
9630}
9631
9632pub(crate) fn scan_path_to_artifacts(
9635 scan_path: &Path,
9636 base_config: &AppConfig,
9637 label: &str,
9638) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9639 let mut config = base_config.clone();
9640 config.discovery.root_paths = vec![scan_path.to_path_buf()];
9641 label.clone_into(&mut config.reporting.report_title);
9642 let run = analyze(&config, "git", None)?;
9643 let html = render_html(&run)?;
9644 let run_id = run.tool.run_id.clone();
9645 let project_label = sanitize_project_label(label);
9646 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9647 let file_stem = {
9648 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9649 if commit.is_empty() {
9650 project_label
9651 } else {
9652 format!("{project_label}_{commit}")
9653 }
9654 };
9655 let (artifacts, _pending_pdf) = persist_run_artifacts(
9656 &run,
9657 &html,
9658 &output_dir,
9659 true,
9660 true,
9661 false,
9662 label,
9663 &file_stem,
9664 RunResultContext::default(),
9665 )?;
9666 Ok((run_id, artifacts, run))
9667}
9668
9669async fn restart_poll_schedules(state: &AppState) {
9671 let store = state.schedules.lock().await;
9672 let poll_schedules: Vec<_> = store
9673 .schedules
9674 .iter()
9675 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9676 .cloned()
9677 .collect();
9678 drop(store);
9679 for schedule in poll_schedules {
9680 let interval = schedule.interval_secs.unwrap_or(300);
9681 let st = state.clone();
9682 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9683 }
9684}
9685
9686fn split_patterns(raw: Option<&str>) -> Vec<String> {
9687 raw.unwrap_or("")
9688 .lines()
9689 .flat_map(|line| line.split(','))
9690 .map(str::trim)
9691 .filter(|part| !part.is_empty())
9692 .map(ToOwned::to_owned)
9693 .collect()
9694}
9695
9696fn build_sub_run(
9697 parent: &AnalysisRun,
9698 sub: &sloc_core::SubmoduleSummary,
9699 parent_path: &str,
9700) -> AnalysisRun {
9701 let sub_files: Vec<_> = parent
9702 .per_file_records
9703 .iter()
9704 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9705 .cloned()
9706 .collect();
9707 let mut config = parent.effective_configuration.clone();
9708 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
9709 AnalysisRun {
9710 tool: parent.tool.clone(),
9711 environment: parent.environment.clone(),
9712 effective_configuration: config,
9713 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
9714 summary_totals: SummaryTotals {
9715 files_considered: sub.files_analyzed,
9716 files_analyzed: sub.files_analyzed,
9717 files_skipped: 0,
9718 total_physical_lines: sub.total_physical_lines,
9719 code_lines: sub.code_lines,
9720 comment_lines: sub.comment_lines,
9721 blank_lines: sub.blank_lines,
9722 mixed_lines_separate: 0,
9723 functions: 0,
9724 classes: 0,
9725 variables: 0,
9726 imports: 0,
9727 test_count: 0,
9728 test_assertion_count: 0,
9729 test_suite_count: 0,
9730 coverage_lines_found: 0,
9731 coverage_lines_hit: 0,
9732 coverage_functions_found: 0,
9733 coverage_functions_hit: 0,
9734 coverage_branches_found: 0,
9735 coverage_branches_hit: 0,
9736 },
9737 totals_by_language: sub.language_summaries.clone(),
9738 per_file_records: sub_files,
9739 skipped_file_records: vec![],
9740 warnings: vec![],
9741 submodule_summaries: vec![],
9742 git_commit_short: parent.git_commit_short.clone(),
9743 git_commit_long: parent.git_commit_long.clone(),
9744 git_branch: parent.git_branch.clone(),
9745 git_commit_author: parent.git_commit_author.clone(),
9746 git_commit_date: parent.git_commit_date.clone(),
9747 git_tags: parent.git_tags.clone(),
9748 git_nearest_tag: parent.git_nearest_tag.clone(),
9749 }
9750}
9751
9752pub(crate) fn sanitize_project_label(raw: &str) -> String {
9753 let candidate = Path::new(raw)
9754 .file_name()
9755 .and_then(|name| name.to_str())
9756 .unwrap_or("project");
9757
9758 let mut value = String::with_capacity(candidate.len());
9759 for ch in candidate.chars() {
9760 if ch.is_ascii_alphanumeric() {
9761 value.push(ch.to_ascii_lowercase());
9762 } else {
9763 value.push('-');
9764 }
9765 }
9766
9767 let compact = value.trim_matches('-').to_string();
9768 if compact.is_empty() {
9769 "project".to_string()
9770 } else {
9771 compact
9772 }
9773}
9774
9775fn strip_unc_prefix(path: PathBuf) -> PathBuf {
9778 let s = path.to_string_lossy();
9779 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9780 return PathBuf::from(format!(r"\\{rest}"));
9781 }
9782 if let Some(rest) = s.strip_prefix(r"\\?\") {
9783 return PathBuf::from(rest);
9784 }
9785 path
9786}
9787
9788fn display_path(path: &Path) -> String {
9789 let s = path.to_string_lossy();
9790 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9795 return format!(r"\\{rest}");
9796 }
9797 if let Some(rest) = s.strip_prefix(r"\\?\") {
9798 return rest.to_owned();
9799 }
9800 s.into_owned()
9801}
9802
9803fn sanitize_path_str(s: &str) -> String {
9804 if let Some(rest) = s.strip_prefix("//?/UNC/") {
9808 return format!("//{rest}");
9809 }
9810 if let Some(rest) = s.strip_prefix("//?/") {
9811 return rest.to_owned();
9812 }
9813 display_path(Path::new(s))
9814}
9815
9816fn workspace_root() -> PathBuf {
9817 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
9819 let p = PathBuf::from(root);
9820 if p.is_dir() {
9821 return p;
9822 }
9823 }
9824
9825 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
9828}
9829
9830fn make_git_label(repo: &str, ref_name: &str) -> String {
9832 if repo.is_empty() || ref_name.is_empty() {
9833 return String::new();
9834 }
9835 let base = repo
9836 .trim_end_matches('/')
9837 .trim_end_matches(".git")
9838 .rsplit('/')
9839 .next()
9840 .unwrap_or("repo");
9841 let ref_safe: String = ref_name
9842 .chars()
9843 .map(|c| {
9844 if c.is_alphanumeric() || c == '-' || c == '.' {
9845 c
9846 } else {
9847 '_'
9848 }
9849 })
9850 .collect();
9851 format!("{base}_at_{ref_safe}_sloc")
9852}
9853
9854fn desktop_dir() -> PathBuf {
9856 if let Ok(profile) = std::env::var("USERPROFILE") {
9857 let p = PathBuf::from(profile).join("Desktop");
9858 if p.exists() {
9859 return p;
9860 }
9861 }
9862 if let Ok(home) = std::env::var("HOME") {
9863 let p = PathBuf::from(home).join("Desktop");
9864 if p.exists() {
9865 return p;
9866 }
9867 }
9868 workspace_root().join("out").join("web")
9869}
9870
9871fn resolve_input_path(raw: &str) -> PathBuf {
9872 let trimmed = raw.trim();
9873 if trimmed.is_empty() {
9874 return workspace_root().join("samples").join("basic");
9875 }
9876
9877 let candidate = PathBuf::from(trimmed);
9878 let resolved = if candidate.is_absolute() {
9879 candidate
9880 } else {
9881 let rooted = workspace_root().join(&candidate);
9882 if rooted.exists() {
9883 rooted
9884 } else {
9885 workspace_root().join(candidate)
9886 }
9887 };
9888
9889 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
9892 PathBuf::from(display_path(&canonical))
9893}
9894
9895fn dir_size_bytes(path: &Path) -> u64 {
9896 let mut total = 0u64;
9897 if let Ok(rd) = fs::read_dir(path) {
9898 for entry in rd.filter_map(Result::ok) {
9899 let p = entry.path();
9900 if p.is_file() {
9901 if let Ok(meta) = p.metadata() {
9902 total += meta.len();
9903 }
9904 } else if p.is_dir() {
9905 total += dir_size_bytes(&p);
9906 }
9907 }
9908 }
9909 total
9910}
9911
9912#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
9914 if bytes >= 1_073_741_824 {
9915 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
9916 } else if bytes >= 1_048_576 {
9917 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
9918 } else if bytes >= 1_024 {
9919 format!("{:.0} KB", bytes as f64 / 1_024.0)
9920 } else {
9921 format!("{bytes} B")
9922 }
9923}
9924
9925fn render_submodule_chips(
9926 root: &Path,
9927 submodules: &[(String, std::path::PathBuf)],
9928 out: &mut String,
9929) {
9930 use std::fmt::Write as _;
9931 let count = submodules.len();
9932 out.push_str(r#"<div class="submodule-preview-strip">"#);
9933 write!(
9934 out,
9935 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>"#,
9936 if count == 1 { "" } else { "s" }
9937 )
9938 .ok();
9939 out.push_str(r#"<div class="submodule-preview-chips">"#);
9940 for (sub_name, sub_rel_path) in submodules {
9941 let sub_abs = root.join(sub_rel_path);
9942 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
9943 let mut sub_stats = PreviewStats::default();
9944 let mut sub_rows: Vec<PreviewRow> = Vec::new();
9945 let mut sub_langs: Vec<&'static str> = Vec::new();
9946 let mut sub_budget = PreviewBudget {
9947 shown: 0,
9948 max_entries: 2000,
9949 max_depth: 9,
9950 };
9951 let mut sub_next_id = 1usize;
9952 let _ = collect_preview_rows(
9953 &sub_abs,
9954 &sub_abs,
9955 0,
9956 None,
9957 &mut sub_next_id,
9958 &mut sub_budget,
9959 &mut sub_stats,
9960 &mut sub_rows,
9961 &mut sub_langs,
9962 &[],
9963 &[],
9964 );
9965 let stats_json = format!(
9966 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
9967 sub_stats.directories,
9968 sub_stats.files,
9969 sub_stats.supported,
9970 sub_stats.skipped,
9971 sub_stats.unsupported
9972 );
9973 write!(
9974 out,
9975 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>"#,
9976 escape_html(sub_name),
9977 escape_html(&sub_rel_path.to_string_lossy()),
9978 escape_html(&sub_size),
9979 escape_html(&stats_json),
9980 escape_html(sub_name),
9981 escape_html(&sub_size),
9982 )
9983 .ok();
9984 }
9985 out.push_str(
9986 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
9987 );
9988 out.push_str(r"</div>");
9989}
9990
9991fn render_language_pills_row(languages: &[&str], out: &mut String) {
9992 use std::fmt::Write as _;
9993 if languages.is_empty() {
9994 out.push_str(
9995 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
9996 );
9997 return;
9998 }
9999 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10000 for language in languages {
10001 if let Some(icon) = language_icon_file(language) {
10002 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();
10003 } else if let Some(svg) = language_inline_svg(language) {
10004 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();
10005 } else {
10006 write!(
10007 out,
10008 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10009 escape_html(&language.to_ascii_lowercase()),
10010 escape_html(language)
10011 )
10012 .ok();
10013 }
10014 }
10015}
10016
10017#[allow(clippy::too_many_lines)]
10018fn build_preview_html(
10019 root: &Path,
10020 include_patterns: &[String],
10021 exclude_patterns: &[String],
10022) -> Result<String> {
10023 if !root.exists() {
10024 return Ok(format!(
10025 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10026 escape_html(&display_path(root))
10027 ));
10028 }
10029
10030 let _selected = display_path(root);
10031 let mut stats = PreviewStats::default();
10032 let mut rows = Vec::new();
10033 let mut languages = Vec::new();
10034 let mut budget = PreviewBudget {
10035 shown: 0,
10036 max_entries: 600,
10037 max_depth: 9,
10038 };
10039 let mut next_row_id = 1usize;
10040
10041 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10042 || root.to_string_lossy().into_owned(),
10043 std::string::ToString::to_string,
10044 );
10045 let root_modified = root
10046 .metadata()
10047 .ok()
10048 .and_then(|meta| meta.modified().ok())
10049 .map_or_else(|| "-".to_string(), format_system_time);
10050
10051 rows.push(PreviewRow {
10052 row_id: 0,
10053 parent_row_id: None,
10054 depth: 0,
10055 name: format!("{root_name}/"),
10056 kind: PreviewKind::Dir,
10057 is_dir: true,
10058 language: None,
10059 modified: root_modified,
10060 type_label: "Directory".to_string(),
10061 });
10062 collect_preview_rows(
10063 root,
10064 root,
10065 0,
10066 Some(0),
10067 &mut next_row_id,
10068 &mut budget,
10069 &mut stats,
10070 &mut rows,
10071 &mut languages,
10072 include_patterns,
10073 exclude_patterns,
10074 )?;
10075
10076 let root_size = format_dir_size(dir_size_bytes(root));
10077
10078 let mut out = String::new();
10079 write!(
10080 out,
10081 r#"<div class="explorer-wrap" data-project-size="{}">"#,
10082 escape_html(&root_size)
10083 )
10084 .ok();
10085 out.push_str(r#"<div class="explorer-toolbar compact">"#);
10086 out.push_str(r#"<div class="explorer-title-group">"#);
10087 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10088 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10089 out.push_str(r"</div></div>");
10090
10091 out.push_str(r#"<div class="scope-stats">"#);
10092 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();
10093 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();
10094 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();
10095 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();
10096 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();
10097 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>"#);
10098 out.push_str(r"</div>");
10099
10100 let submodules = sloc_core::detect_submodules(root);
10101 if !submodules.is_empty() {
10102 render_submodule_chips(root, &submodules, &mut out);
10103 }
10104
10105 out.push_str(r#"<div class="scope-info-row">"#);
10106 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10107 render_language_pills_row(&languages, &mut out);
10108 out.push_str(r"</div></div>");
10109 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>"#);
10110 out.push_str(r"</div>");
10111
10112 out.push_str(r#"<div class="file-explorer-shell">"#);
10113 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>"#);
10114 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>"#);
10115 out.push_str(r#"<div class="file-explorer-tree">"#);
10116 for row in rows {
10117 let status_label = row.kind.label();
10118 let lang_attr = row.language.unwrap_or("");
10119 let toggle_html = if row.is_dir {
10120 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10121 .to_string()
10122 } else {
10123 r#"<span class="tree-bullet">•</span>"#.to_string()
10124 };
10125 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();
10126 }
10127 if budget.shown >= budget.max_entries {
10128 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>"#);
10129 }
10130 out.push_str(r"</div></div></div>");
10131
10132 Ok(out)
10133}
10134
10135#[derive(Default)]
10136struct PreviewStats {
10137 directories: usize,
10138 files: usize,
10139 supported: usize,
10140 skipped: usize,
10141 unsupported: usize,
10142}
10143
10144struct PreviewRow {
10145 row_id: usize,
10146 parent_row_id: Option<usize>,
10147 depth: usize,
10148 name: String,
10149 kind: PreviewKind,
10150 is_dir: bool,
10151 language: Option<&'static str>,
10152 modified: String,
10153 type_label: String,
10154}
10155
10156#[derive(Copy, Clone)]
10157enum PreviewKind {
10158 Dir,
10159 Supported,
10160 Skipped,
10161 Unsupported,
10162}
10163
10164impl PreviewKind {
10165 const fn filter_key(self) -> &'static str {
10166 match self {
10167 Self::Dir => "dir",
10168 Self::Supported => "supported",
10169 Self::Skipped => "skipped",
10170 Self::Unsupported => "unsupported",
10171 }
10172 }
10173
10174 const fn label(self) -> &'static str {
10175 match self {
10176 Self::Dir => "dir",
10177 Self::Supported => "supported",
10178 Self::Skipped => "skipped by policy",
10179 Self::Unsupported => "unsupported",
10180 }
10181 }
10182
10183 const fn badge_class(self) -> &'static str {
10184 match self {
10185 Self::Dir => "badge badge-dir",
10186 Self::Supported => "badge badge-scan",
10187 Self::Skipped => "badge badge-skip",
10188 Self::Unsupported => "badge badge-unsupported",
10189 }
10190 }
10191
10192 const fn node_class(self) -> &'static str {
10193 match self {
10194 Self::Dir => "tree-node-dir",
10195 Self::Supported => "tree-node-supported",
10196 Self::Skipped => "tree-node-skipped",
10197 Self::Unsupported => "tree-node-unsupported",
10198 }
10199 }
10200}
10201
10202struct PreviewBudget {
10203 shown: usize,
10204 max_entries: usize,
10205 max_depth: usize,
10206}
10207
10208#[allow(clippy::too_many_arguments)]
10211fn handle_preview_dir_entry(
10212 root: &Path,
10213 path: &Path,
10214 name: &str,
10215 modified: String,
10216 depth: usize,
10217 parent_row_id: Option<usize>,
10218 row_id: usize,
10219 next_row_id: &mut usize,
10220 budget: &mut PreviewBudget,
10221 stats: &mut PreviewStats,
10222 rows: &mut Vec<PreviewRow>,
10223 languages: &mut Vec<&'static str>,
10224 include_patterns: &[String],
10225 exclude_patterns: &[String],
10226) -> Result<()> {
10227 let relative = preview_relative_path(root, path);
10228 if should_skip_preview_directory(&relative, exclude_patterns) {
10229 return Ok(());
10230 }
10231 stats.directories += 1;
10232 rows.push(PreviewRow {
10233 row_id,
10234 parent_row_id,
10235 depth: depth + 1,
10236 name: format!("{name}/"),
10237 kind: PreviewKind::Dir,
10238 is_dir: true,
10239 language: None,
10240 modified,
10241 type_label: "Directory".to_string(),
10242 });
10243 budget.shown += 1;
10244 if !matches!(name, ".git" | "node_modules" | "target") {
10245 collect_preview_rows(
10246 root,
10247 path,
10248 depth + 1,
10249 Some(row_id),
10250 next_row_id,
10251 budget,
10252 stats,
10253 rows,
10254 languages,
10255 include_patterns,
10256 exclude_patterns,
10257 )?;
10258 }
10259 Ok(())
10260}
10261
10262#[allow(clippy::too_many_arguments)]
10264fn handle_preview_file_entry(
10265 root: &Path,
10266 path: &Path,
10267 name: &str,
10268 modified: String,
10269 depth: usize,
10270 parent_row_id: Option<usize>,
10271 row_id: usize,
10272 budget: &mut PreviewBudget,
10273 stats: &mut PreviewStats,
10274 rows: &mut Vec<PreviewRow>,
10275 languages: &mut Vec<&'static str>,
10276 include_patterns: &[String],
10277 exclude_patterns: &[String],
10278) {
10279 let relative = preview_relative_path(root, path);
10280 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10281 return;
10282 }
10283 stats.files += 1;
10284 let kind = classify_preview_file(name);
10285 match kind {
10286 PreviewKind::Supported => stats.supported += 1,
10287 PreviewKind::Skipped => stats.skipped += 1,
10288 PreviewKind::Unsupported => stats.unsupported += 1,
10289 PreviewKind::Dir => {}
10290 }
10291 let language = detect_language_name(name);
10292 if let Some(lang) = language {
10293 if !languages.contains(&lang) {
10294 languages.push(lang);
10295 }
10296 }
10297 rows.push(PreviewRow {
10298 row_id,
10299 parent_row_id,
10300 depth: depth + 1,
10301 name: name.to_owned(),
10302 kind,
10303 is_dir: false,
10304 language,
10305 modified,
10306 type_label: preview_type_label(name, language, kind),
10307 });
10308 budget.shown += 1;
10309}
10310
10311#[allow(clippy::too_many_arguments)]
10312#[allow(clippy::too_many_lines)]
10313fn collect_preview_rows(
10314 root: &Path,
10315 dir: &Path,
10316 depth: usize,
10317 parent_row_id: Option<usize>,
10318 next_row_id: &mut usize,
10319 budget: &mut PreviewBudget,
10320 stats: &mut PreviewStats,
10321 rows: &mut Vec<PreviewRow>,
10322 languages: &mut Vec<&'static str>,
10323 include_patterns: &[String],
10324 exclude_patterns: &[String],
10325) -> Result<()> {
10326 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10327 return Ok(());
10328 }
10329
10330 let mut entries = fs::read_dir(dir)
10331 .with_context(|| format!("failed to read directory {}", dir.display()))?
10332 .filter_map(std::result::Result::ok)
10333 .collect::<Vec<_>>();
10334 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10335
10336 for entry in entries {
10337 if budget.shown >= budget.max_entries {
10338 break;
10339 }
10340
10341 let path = entry.path();
10342 let name = entry.file_name().to_string_lossy().into_owned();
10343 let Ok(metadata) = entry.metadata() else {
10344 continue;
10345 };
10346 let row_id = *next_row_id;
10347 *next_row_id += 1;
10348 let modified = metadata
10349 .modified()
10350 .ok()
10351 .map_or_else(|| "-".to_string(), format_system_time);
10352
10353 if metadata.is_dir() {
10354 handle_preview_dir_entry(
10355 root,
10356 &path,
10357 &name,
10358 modified,
10359 depth,
10360 parent_row_id,
10361 row_id,
10362 next_row_id,
10363 budget,
10364 stats,
10365 rows,
10366 languages,
10367 include_patterns,
10368 exclude_patterns,
10369 )?;
10370 continue;
10371 }
10372
10373 if metadata.is_file() {
10374 handle_preview_file_entry(
10375 root,
10376 &path,
10377 &name,
10378 modified,
10379 depth,
10380 parent_row_id,
10381 row_id,
10382 budget,
10383 stats,
10384 rows,
10385 languages,
10386 include_patterns,
10387 exclude_patterns,
10388 );
10389 }
10390 }
10391
10392 Ok(())
10393}
10394
10395fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10396 if let Some(language) = language {
10397 return format!("{language} source");
10398 }
10399 let lower = name.to_ascii_lowercase();
10400 let ext = Path::new(&lower)
10401 .extension()
10402 .and_then(|e| e.to_str())
10403 .unwrap_or("");
10404 match kind {
10405 PreviewKind::Skipped => {
10406 if lower.ends_with(".min.js") {
10407 "Minified asset".to_string()
10408 } else if [
10409 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10410 ]
10411 .contains(&ext)
10412 {
10413 "Binary or archive".to_string()
10414 } else {
10415 "Skipped file".to_string()
10416 }
10417 }
10418 PreviewKind::Unsupported => {
10419 if ext.is_empty() {
10420 "Unsupported file".to_string()
10421 } else {
10422 format!("{} file", ext.to_ascii_uppercase())
10423 }
10424 }
10425 PreviewKind::Supported => "Supported source".to_string(),
10426 PreviewKind::Dir => "Directory".to_string(),
10427 }
10428}
10429
10430fn format_system_time(time: SystemTime) -> String {
10431 #[allow(clippy::cast_possible_wrap)]
10432 let secs = match time.duration_since(UNIX_EPOCH) {
10433 Ok(duration) => duration.as_secs() as i64,
10434 Err(_) => return "-".to_string(),
10435 };
10436 let days = secs.div_euclid(86_400);
10437 let secs_of_day = secs.rem_euclid(86_400);
10438 let (year, month, day) = civil_from_days(days);
10439 let hour = secs_of_day / 3_600;
10440 let minute = (secs_of_day % 3_600) / 60;
10441 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10442}
10443
10444#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10445fn civil_from_days(days: i64) -> (i32, u32, u32) {
10446 let z = days + 719_468;
10447 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10448 let doe = z - era * 146_097;
10449 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10450 let y = yoe + era * 400;
10451 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10452 let mp = (5 * doy + 2) / 153;
10453 let d = doy - (153 * mp + 2) / 5 + 1;
10454 let m = mp + if mp < 10 { 3 } else { -9 };
10455 let year = y + i64::from(m <= 2);
10456 (year as i32, m as u32, d as u32)
10457}
10458
10459#[allow(clippy::case_sensitive_file_extension_comparisons)]
10462fn detect_language_name(name: &str) -> Option<&'static str> {
10463 let lower = name.to_ascii_lowercase();
10464 if lower.ends_with(".c") || lower.ends_with(".h") {
10465 Some("C")
10466 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10467 .iter()
10468 .any(|s| lower.ends_with(s))
10469 {
10470 Some("C++")
10471 } else if lower.ends_with(".cs") {
10472 Some("C#")
10473 } else if lower.ends_with(".py") {
10474 Some("Python")
10475 } else if lower.ends_with(".sh") {
10476 Some("Shell")
10477 } else if [".ps1", ".psm1", ".psd1"]
10478 .iter()
10479 .any(|s| lower.ends_with(s))
10480 {
10481 Some("PowerShell")
10482 } else {
10483 None
10484 }
10485}
10486
10487fn language_icon_file(language: &str) -> Option<&'static str> {
10488 match language {
10489 "C" => Some("c.png"),
10490 "C++" => Some("cpp.png"),
10491 "C#" => Some("c-sharp.png"),
10492 "Python" => Some("python.png"),
10493 "Shell" => Some("shell.png"),
10494 "PowerShell" => Some("powershell.png"),
10495 "JavaScript" => Some("java-script.png"),
10496 "HTML" => Some("html-5.png"),
10497 "Java" => Some("java.png"),
10498 "Visual Basic" => Some("visual-basic.png"),
10499 "Assembly" => Some("asm.png"),
10500 "Go" => Some("go.png"),
10501 "R" => Some("r.png"),
10502 "XML" => Some("xml.png"),
10503 "Groovy" => Some("groovy.png"),
10504 "Dockerfile" => Some("docker.png"),
10505 "Makefile" => Some("makefile.svg"),
10506 "Perl" => Some("perl.svg"),
10507 _ => None,
10508 }
10509}
10510
10511fn language_inline_svg(language: &str) -> Option<&'static str> {
10516 match language {
10517 "Rust" => Some(
10518 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>"##,
10519 ),
10520 "TypeScript" => Some(
10521 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>"##,
10522 ),
10523 _ => None,
10524 }
10525}
10526
10527#[allow(clippy::case_sensitive_file_extension_comparisons)]
10530fn classify_preview_file(name: &str) -> PreviewKind {
10531 let lower = name.to_ascii_lowercase();
10532
10533 let scannable = [
10534 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10535 ".psm1", ".psd1",
10536 ]
10537 .iter()
10538 .any(|suffix| lower.ends_with(suffix));
10539
10540 if scannable {
10541 PreviewKind::Supported
10542 } else if lower.ends_with(".min.js")
10543 || lower.ends_with(".lock")
10544 || lower.ends_with(".png")
10545 || lower.ends_with(".jpg")
10546 || lower.ends_with(".jpeg")
10547 || lower.ends_with(".gif")
10548 || lower.ends_with(".zip")
10549 || lower.ends_with(".pdf")
10550 || lower.ends_with(".pyc")
10551 || lower.ends_with(".xz")
10552 || lower.ends_with(".tar")
10553 || lower.ends_with(".gz")
10554 {
10555 PreviewKind::Skipped
10556 } else {
10557 PreviewKind::Unsupported
10558 }
10559}
10560
10561fn preview_relative_path(root: &Path, path: &Path) -> String {
10562 path.strip_prefix(root)
10563 .ok()
10564 .unwrap_or(path)
10565 .to_string_lossy()
10566 .replace('\\', "/")
10567 .trim_matches('/')
10568 .to_string()
10569}
10570
10571fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10572 if relative.is_empty() {
10573 return false;
10574 }
10575
10576 exclude_patterns.iter().any(|pattern| {
10577 wildcard_match(pattern, relative)
10578 || wildcard_match(pattern, &format!("{relative}/"))
10579 || wildcard_match(pattern, &format!("{relative}/placeholder"))
10580 })
10581}
10582
10583fn should_include_preview_file(
10584 relative: &str,
10585 include_patterns: &[String],
10586 exclude_patterns: &[String],
10587) -> bool {
10588 if relative.is_empty() {
10589 return true;
10590 }
10591
10592 let included = include_patterns.is_empty()
10593 || include_patterns
10594 .iter()
10595 .any(|pattern| wildcard_match(pattern, relative));
10596 let excluded = exclude_patterns
10597 .iter()
10598 .any(|pattern| wildcard_match(pattern, relative));
10599
10600 included && !excluded
10601}
10602
10603fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10604 let pattern = pattern.trim().replace('\\', "/");
10605 let candidate = candidate.trim().replace('\\', "/");
10606 let p = pattern.as_bytes();
10607 let c = candidate.as_bytes();
10608 let mut pi = 0usize;
10609 let mut ci = 0usize;
10610 let mut star: Option<usize> = None;
10611 let mut star_match = 0usize;
10612
10613 while ci < c.len() {
10614 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10615 pi += 1;
10616 ci += 1;
10617 } else if pi < p.len() && p[pi] == b'*' {
10618 while pi < p.len() && p[pi] == b'*' {
10619 pi += 1;
10620 }
10621 star = Some(pi);
10622 star_match = ci;
10623 } else if let Some(star_pi) = star {
10624 star_match += 1;
10625 ci = star_match;
10626 pi = star_pi;
10627 } else {
10628 return false;
10629 }
10630 }
10631
10632 while pi < p.len() && p[pi] == b'*' {
10633 pi += 1;
10634 }
10635
10636 pi == p.len()
10637}
10638
10639fn escape_html(value: &str) -> String {
10640 value
10641 .replace('&', "&")
10642 .replace('<', "<")
10643 .replace('>', ">")
10644 .replace('"', """)
10645 .replace('\'', "'")
10646}
10647
10648#[derive(Clone)]
10649struct SubmoduleRow {
10650 name: String,
10651 relative_path: String,
10652 files_analyzed: u64,
10653 code_lines: u64,
10654 comment_lines: u64,
10655 blank_lines: u64,
10656 total_physical_lines: u64,
10657 html_url: Option<String>,
10658}
10659
10660#[derive(Template)]
10661#[template(
10662 source = r##"
10663<!doctype html>
10664<html lang="en">
10665<head>
10666 <meta charset="utf-8">
10667 <title>OxideSLOC | tmp-sloc</title>
10668 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10669 <style nonce="{{ csp_nonce }}">
10670 :root {
10671 --bg: #efe9e2;
10672 --surface: #fcfaf7;
10673 --surface-2: #f7f0e8;
10674 --surface-3: #efe3d5;
10675 --line: #dfcfbf;
10676 --line-strong: #cfb29c;
10677 --text: #2f241c;
10678 --muted: #6f6257;
10679 --muted-2: #917f71;
10680 --nav: #b85d33;
10681 --nav-2: #7a371b;
10682 --accent: #2563eb;
10683 --accent-2: #1d4ed8;
10684 --oxide: #b85d33;
10685 --oxide-2: #8f4220;
10686 --success-bg: #eaf9ee;
10687 --success-text: #1c8746;
10688 --warn-bg: #fff2d8;
10689 --warn-text: #926000;
10690 --danger-bg: #fdeaea;
10691 --danger-text: #b33b3b;
10692 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
10693 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
10694 --radius: 14px;
10695 }
10696
10697 body.dark-theme {
10698 --bg: #1b1511;
10699 --surface: #261c17;
10700 --surface-2: #2d221d;
10701 --surface-3: #372922;
10702 --line: #524238;
10703 --line-strong: #6c5649;
10704 --text: #f5ece6;
10705 --muted: #c7b7aa;
10706 --muted-2: #aa9485;
10707 --nav: #b85d33;
10708 --nav-2: #7a371b;
10709 --accent: #6f9bff;
10710 --accent-2: #4a78ee;
10711 --oxide: #d37a4c;
10712 --oxide-2: #b35428;
10713 --success-bg: #163927;
10714 --success-text: #8fe2a8;
10715 --warn-bg: #3c2d11;
10716 --warn-text: #f3cb75;
10717 --danger-bg: #3d1f1f;
10718 --danger-text: #ff9f9f;
10719 --shadow: 0 14px 28px rgba(0,0,0,0.28);
10720 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
10721 }
10722
10723 * { box-sizing: border-box; }
10724 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); }
10725 html { overflow-y: scroll; }
10726 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
10727 .top-nav, .page, .loading { position: relative; z-index: 2; }
10728 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
10729 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
10730 .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); }
10731 .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; }
10732 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
10733 .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)); }
10734 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
10735 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
10736 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
10737 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
10738 .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; }
10739 .nav-project-pill.visible { display:inline-flex; }
10740 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
10741 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
10742 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
10743 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
10744 @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; } }
10745 .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; }
10746 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
10747 .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; }
10748 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
10749 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
10750 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
10751 .theme-toggle .icon-sun { display:none; }
10752 body.dark-theme .theme-toggle .icon-sun { display:block; }
10753 body.dark-theme .theme-toggle .icon-moon { display:none; }
10754 .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;}
10755 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
10756 .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);}
10757 .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;}
10758 .settings-close:hover{color:var(--text);background:var(--surface-2);}
10759 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
10760 .settings-modal-body{padding:14px 16px 16px;}
10761 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
10762 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
10763 .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;}
10764 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
10765 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
10766 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
10767 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
10768 .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;}
10769 .tz-select:focus{border-color:var(--oxide);}
10770 .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; }
10771 .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;}
10772 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
10773 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
10774 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
10775 .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; }
10776 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
10777 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
10778 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
10779 .wb-stats-header { padding: 10px 24px 0; }
10780 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
10781 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
10782 .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; }
10783 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10784 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10785 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10786 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
10787 .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; }
10788 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
10789 .ws-stat-analyzers { position: relative; }
10790 .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; }
10791 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
10792 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
10793 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
10794 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
10795 .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; }
10796 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
10797 .ws-divider { display: none; }
10798 .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%; }
10799 .ws-path-link:hover { color:var(--oxide); }
10800 body.dark-theme .ws-path-link { color:var(--oxide); }
10801 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
10802 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10803 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
10804 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10805 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
10806 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
10807 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
10808 .ws-mini-box-lg { flex:2 1 0; }
10809 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
10810 .ws-mini-box-br { flex:1.5 1 0; }
10811 .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); }
10812 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
10813 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
10814 #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; }
10815 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
10816 .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; }
10817 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
10818 .git-source-banner strong { font-weight:800; color:var(--text); }
10819 .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; }
10820 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
10821 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
10822 .git-source-banner a:hover { text-decoration:underline; }
10823 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
10824 .path-scope-sep { background:var(--line); margin:4px 14px; }
10825 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
10826 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
10827 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
10828 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
10829 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
10830 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
10831 .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; }
10832 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10833 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10834 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10835 .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; }
10836 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
10837 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
10838 [data-wb-tip] { cursor:help; }
10839 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
10840 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
10841 .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; }
10842 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
10843 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
10844 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
10845 .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; }
10846 .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); }
10847 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
10848 .side-info-card { padding: 18px; }
10849 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
10850 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
10851 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
10852 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
10853 .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); }
10854 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
10855 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10856 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
10857 .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; }
10858 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
10859 .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; }
10860 .side-stack::-webkit-scrollbar { display: none; }
10861 .step-nav { padding: 20px 16px; }
10862 .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); }
10863 .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; }
10864 .step-button:hover { background: var(--surface-2); }
10865 .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); }
10866 .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; }
10867 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
10868 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
10869 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
10870 .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); }
10871 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
10872 .step-nav-sum-row:last-child { border-bottom:none; }
10873 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
10874 .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; }
10875 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
10876 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
10877 .quick-scan-section { padding: 10px 4px 14px; }
10878 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
10879 .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; }
10880 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
10881 .quick-scan-btn:active { transform:translateY(0); }
10882 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
10883 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
10884 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
10885 @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);} }
10886 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
10887 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
10888 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
10889 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
10890 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
10891 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
10892 .step-button.done .step-check { opacity:1; }
10893 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
10894 .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; }
10895 .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; }
10896 .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; }
10897 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
10898 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
10899 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
10900 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
10901 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
10902 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
10903 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
10904 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
10905 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
10906 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
10907 .card-body { padding: 22px; }
10908 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
10909 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
10910 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
10911 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
10912 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
10913 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10914 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
10915 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
10916 .field { min-width:0; }
10917 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
10918 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; }
10919 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); }
10920 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
10921 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); }
10922 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
10923 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10924 .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; }
10925 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
10926 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
10927 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
10928 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
10929 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
10930 .input-group.compact { grid-template-columns: 1fr auto auto; }
10931 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
10932 .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)); }
10933 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
10934 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
10935 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
10936 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
10937 .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; }
10938 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
10939 .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; }
10940 .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); }
10941 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
10942 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
10943 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
10944 button.secondary { background: var(--surface); }
10945 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
10946 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
10947 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
10948 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
10949 .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); }
10950 .section + .wizard-actions { border-top: none; padding-top: 0; }
10951 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
10952 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
10953 .field-help-grid.coupled-help { margin-top: 12px; }
10954 .field-help-grid.preset-grid { align-items: start; }
10955 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
10956 .preset-inline-row .field { margin: 0; }
10957 .preset-inline-row .explainer-card { margin: 0; }
10958 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
10959 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
10960 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
10961 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
10962 .preset-kv-row > :last-child { flex:1; min-width:0; }
10963 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
10964 .output-field-row .field { margin: 0; }
10965 .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; }
10966 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
10967 .step3-subtitle { margin-bottom: 10px; max-width: none; }
10968 .counting-intro { margin-bottom: 8px; max-width: none; }
10969 .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; }
10970 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
10971 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
10972 .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; }
10973 .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; }
10974 .section-spacer-top { margin-top: 28px; }
10975 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
10976 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
10977 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
10978 .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); }
10979 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
10980 .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; }
10981 .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; }
10982 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
10983 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
10984 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
10985 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
10986 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
10987 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
10988 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
10989 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
10990 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
10991 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
10992 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
10993 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
10994 .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); }
10995 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
10996 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
10997 .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; }
10998 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
10999 .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; }
11000 .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; }
11001 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11002 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11003 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11004 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11005 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11006 .advanced-rule-description strong { color: var(--text); }
11007 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11008 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11009 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11010 .review-link:hover { text-decoration: underline; }
11011 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11012 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11013 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11014 .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; }
11015 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11016 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11017 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11018 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11019 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11020 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11021 .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; }
11022 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11023 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11024 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11025 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11026 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11027 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11028 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11029 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11030 .review-card ul { padding-left: 18px; margin: 0; }
11031 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11032 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11033 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11034 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11035 .review-card { min-height: 0; }
11036 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11037 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11038 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11039 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11040 .lang-overflow-chip { position:relative; cursor:default; }
11041 .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; }
11042 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11043 .git-inline-row { align-items:start; }
11044 .mixed-line-card { display:flex; flex-direction:column; }
11045 .preset-inline-row .toggle-card { justify-content: center; }
11046 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11047 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11048 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11049 .explorer-title { font-size: 18px; font-weight: 850; }
11050 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11051 .explorer-subtitle.wide { max-width: none; }
11052 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11053 .better-spacing { align-items:flex-start; justify-content:flex-end; }
11054 .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; }
11055 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11056 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11057 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11058 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11059 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11060 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11061 .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; }
11062 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11063 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11064 .scope-stat-button.supported { background: var(--success-bg); }
11065 .scope-stat-button.skipped { background: var(--warn-bg); }
11066 .scope-stat-button.unsupported { background: var(--danger-bg); }
11067 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11068 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11069 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11070 [data-tooltip] { position: relative; }
11071 [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); }
11072 [data-tooltip]:hover::after { display: block; }
11073 .scope-stat-button[data-tooltip] { cursor: pointer; }
11074 .badge[data-tooltip] { cursor: help; }
11075 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11076 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11077 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11078 .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; }
11079 .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; }
11080 code { display:inline-block; margin-top:0; padding:2px 7px; }
11081 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11082 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11083 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11084 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11085 .language-pill.muted-pill { color: var(--muted); }
11086 button.language-pill { appearance:none; cursor:pointer; }
11087 .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); }
11088 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11089 .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; }
11090 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11091 .file-explorer-search-row { margin-left: auto; }
11092 .explorer-filter-select { min-width: 170px; width: 170px; }
11093 .explorer-search { min-width: 300px; width: 300px; }
11094 .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); }
11095 .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; }
11096 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11097 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11098 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11099 .file-explorer-tree { max-height: 640px; overflow:auto; }
11100 .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); }
11101 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11102 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11103 .tree-row.hidden-by-filter { display:none !important; }
11104 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11105 .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; }
11106 .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; }
11107 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11108 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11109 .tree-node { display:inline-flex; align-items:center; min-width:0; }
11110 .tree-node-dir { color: var(--text); font-weight: 800; }
11111 .tree-node-supported { color: var(--success-text); }
11112 .tree-node-skipped { color: var(--warn-text); }
11113 .tree-node-unsupported { color: var(--danger-text); }
11114 .tree-node-more { color: var(--muted-2); font-style: italic; }
11115 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11116 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11117 .tree-status-cell { display:flex; justify-content:flex-start; }
11118 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11119 .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; }
11120 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11121 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11122 .cov-scan-idle { display:none; }
11123 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11124 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11125 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11126 .cov-scan-title { font-weight:600; font-size:12.5px; }
11127 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11128 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11129 .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; }
11130 .cov-scan-use:hover { opacity:.75; }
11131 .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; }
11132 .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; }
11133 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11134 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11135 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11136 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11137 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11138 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11139 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11140 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11141 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11142 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11143 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11144 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11145 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11146 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11147 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11148 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11149 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11150 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11151 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11152 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11153 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11154 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11155 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11156 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11157 .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); }
11158 .loading.active { display:flex; }
11159 .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; }
11160 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11161 .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; }
11162 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11163 .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; }
11164 .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; }
11165 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11166 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11167 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11168 .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; }
11169 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11170 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11171 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11172 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11173 .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; }
11174 .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; }
11175 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11176 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11177 .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; }
11178 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11179 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11180 .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; }
11181 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11182 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11183 .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; }
11184 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11185 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11186 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11187 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11188 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11189 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11190 .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; }
11191 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11192 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11193 .hidden { display:none !important; }
11194 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11195 .site-footer a{color:var(--muted);}
11196 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11197 @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; } }
11198 .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;}
11199 @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));}}
11200 .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;}
11201 .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; }
11202 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11203 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11204 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11205 .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; }
11206 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11207 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11208 .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; }
11209 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11210 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11211 .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; }
11212 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11213 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11214 .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; }
11215 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11216 .info-icon-btn:hover { color:var(--text); }
11217 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); }
11218 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11219 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11220 .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;}
11221 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11222 .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;}
11223 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11224 </style>
11225</head>
11226<body>
11227 <div class="background-watermarks" aria-hidden="true">
11228 <img src="/images/logo/logo-text.png" alt="" />
11229 <img src="/images/logo/logo-text.png" alt="" />
11230 <img src="/images/logo/logo-text.png" alt="" />
11231 <img src="/images/logo/logo-text.png" alt="" />
11232 <img src="/images/logo/logo-text.png" alt="" />
11233 <img src="/images/logo/logo-text.png" alt="" />
11234 <img src="/images/logo/logo-text.png" alt="" />
11235 <img src="/images/logo/logo-text.png" alt="" />
11236 <img src="/images/logo/logo-text.png" alt="" />
11237 <img src="/images/logo/logo-text.png" alt="" />
11238 <img src="/images/logo/logo-text.png" alt="" />
11239 <img src="/images/logo/logo-text.png" alt="" />
11240 <img src="/images/logo/logo-text.png" alt="" />
11241 <img src="/images/logo/logo-text.png" alt="" />
11242 </div>
11243 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11244 <div class="top-nav">
11245 <div class="top-nav-inner">
11246 <a class="brand" href="/">
11247 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11248 <div class="brand-copy">
11249 <div class="brand-title">OxideSLOC</div>
11250 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11251 </div>
11252 </a>
11253 <div class="nav-project-slot">
11254 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11255 <span class="nav-project-label">Project</span>
11256 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11257 </div>
11258 </div>
11259 <div class="nav-status">
11260 <a class="nav-pill" href="/">Home</a>
11261 <div class="nav-dropdown">
11262 <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>
11263 <div class="nav-dropdown-menu">
11264 <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>
11265 </div>
11266 </div>
11267 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11268 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11269 <div class="nav-dropdown">
11270 <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>
11271 <div class="nav-dropdown-menu">
11272 <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>
11273 </div>
11274 </div>
11275 <div class="server-status-wrap" id="server-status-wrap">
11276 <div class="nav-pill server-online-pill" id="server-status-pill">
11277 <span class="status-dot" id="status-dot"></span>
11278 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11279 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11280 </div>
11281 <div class="server-status-tip">
11282 {% if server_mode %}
11283 OxideSLOC is running in server mode — accessible on your LAN.
11284 {% else %}
11285 OxideSLOC is running locally — only accessible from this machine.
11286 {% endif %}
11287 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11288 </div>
11289 </div>
11290 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11291 <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>
11292 </button>
11293 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11294 <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>
11295 <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>
11296 </button>
11297 </div>
11298 </div>
11299 </div>
11300
11301 <div class="loading" id="loading">
11302 <div class="loading-card">
11303 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11304 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11305 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
11306 <div class="lc-path" id="lc-path"></div>
11307 <div class="lc-metrics" id="lc-metrics">
11308 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11309 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11310 </div>
11311 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11312 <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>
11313 <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>
11314 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11315 <div class="lc-actions hidden" id="lc-actions">
11316 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11317 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11318 </div>
11319 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11320 <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>
11321 Cancel scan
11322 </button>
11323 </div>
11324 </div>
11325
11326 <div class="page">
11327 <div class="workbench-strip">
11328 <div class="workbench-box wb-stats">
11329 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11330 <span class="wb-stats-title">Analysis session</span>
11331 </div>
11332 <div class="ws-left">
11333 <div class="ws-stat ws-stat-analyzers">
11334 <span class="ws-label">Analyzers</span>
11335 <span class="ws-value">
11336 <span class="ws-badge">41 languages</span>
11337 </span>
11338 <div class="ws-lang-tooltip">
11339 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11340 <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>
11341 <div class="ws-lang-grid">
11342 <span class="ws-lang-item">Assembly</span>
11343 <span class="ws-lang-item">C</span>
11344 <span class="ws-lang-item">C++</span>
11345 <span class="ws-lang-item">C#</span>
11346 <span class="ws-lang-item">Clojure</span>
11347 <span class="ws-lang-item">CSS</span>
11348 <span class="ws-lang-item">Dart</span>
11349 <span class="ws-lang-item">Dockerfile</span>
11350 <span class="ws-lang-item">Elixir</span>
11351 <span class="ws-lang-item">Erlang</span>
11352 <span class="ws-lang-item">F#</span>
11353 <span class="ws-lang-item">Go</span>
11354 <span class="ws-lang-item">Groovy</span>
11355 <span class="ws-lang-item">Haskell</span>
11356 <span class="ws-lang-item">HTML</span>
11357 <span class="ws-lang-item">Java</span>
11358 <span class="ws-lang-item">JavaScript</span>
11359 <span class="ws-lang-item">Julia</span>
11360 <span class="ws-lang-item">Kotlin</span>
11361 <span class="ws-lang-item">Lua</span>
11362 <span class="ws-lang-item">Makefile</span>
11363 <span class="ws-lang-item">Nim</span>
11364 <span class="ws-lang-item">Obj-C</span>
11365 <span class="ws-lang-item">OCaml</span>
11366 <span class="ws-lang-item">Perl</span>
11367 <span class="ws-lang-item">PHP</span>
11368 <span class="ws-lang-item">PowerShell</span>
11369 <span class="ws-lang-item">Python</span>
11370 <span class="ws-lang-item">R</span>
11371 <span class="ws-lang-item">Ruby</span>
11372 <span class="ws-lang-item">Rust</span>
11373 <span class="ws-lang-item">Scala</span>
11374 <span class="ws-lang-item">SCSS</span>
11375 <span class="ws-lang-item">Shell</span>
11376 <span class="ws-lang-item">SQL</span>
11377 <span class="ws-lang-item">Svelte</span>
11378 <span class="ws-lang-item">Swift</span>
11379 <span class="ws-lang-item">TypeScript</span>
11380 <span class="ws-lang-item">Vue</span>
11381 <span class="ws-lang-item">XML</span>
11382 <span class="ws-lang-item">Zig</span>
11383 </div>
11384 </div>
11385 </div>
11386 <div class="ws-divider"></div>
11387 <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>
11388 <div class="ws-divider"></div>
11389 <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.">
11390 <span class="ws-label">Output</span>
11391 <span class="ws-value">
11392 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11393 <span id="ws-output-root">project/sloc</span>
11394 </button>
11395 </span>
11396 </div>
11397 </div>
11398 </div>
11399 <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.">
11400 <div class="ws-history-label">Scan history</div>
11401 <div class="ws-history-inner">
11402 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11403 <div class="ws-mini-label">Scans</div>
11404 <div class="ws-mini-value" id="ws-scan-count">—</div>
11405 </div>
11406 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11407 <div class="ws-mini-label">Last Scan</div>
11408 <div class="ws-mini-value" id="ws-last-scan">—</div>
11409 </div>
11410 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11411 <div class="ws-mini-label">Branch</div>
11412 <div class="ws-mini-value" id="ws-branch">—</div>
11413 </div>
11414 </div>
11415 </div>
11416 </div>
11417
11418 <div class="layout">
11419 <aside class="side-stack">
11420 <section class="step-nav">
11421 <h3>Guided scan setup</h3>
11422 <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>
11423 <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>
11424 <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>
11425 <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>
11426
11427 <div class="step-steps-divider"></div>
11428
11429 <div class="step-nav-info" id="step-nav-info">
11430 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11431 <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>
11432 </div>
11433
11434 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11435 <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>
11436 <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>
11437 <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>
11438 </div>
11439
11440 <div class="quick-scan-divider"></div>
11441 <div class="quick-scan-section">
11442 <div class="quick-scan-label">No customization needed?</div>
11443 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11444 <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>
11445 Quick Scan
11446 </button>
11447 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11448 </div>
11449
11450 <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>
11451 </section>
11452
11453 </aside>
11454
11455 <section class="card">
11456 <div class="card-header">
11457 <div class="card-title-row">
11458 <div>
11459 <h1 class="card-title">Guided scan configuration</h1>
11460 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11461 </div>
11462 <div class="wizard-progress" aria-label="Scan setup progress">
11463 <div class="wizard-progress-top">
11464 <span class="wizard-progress-label">Setup progress</span>
11465 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11466 </div>
11467 <div class="wizard-progress-track">
11468 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11469 </div>
11470 </div>
11471 </div>
11472 </div>
11473 <div class="card-body">
11474 <form method="post" action="/analyze" id="analyze-form">
11475 <div class="wizard-step active" data-step="1">
11476 <div class="section">
11477 <div class="section-kicker">Step 1</div>
11478 <h2>Select project and preview scope</h2>
11479 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11480 <div class="field">
11481 <label for="path">Project path</label>
11482 {% if !git_repo.is_empty() %}
11483 <div class="git-source-banner">
11484 <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>
11485 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11486 <a href="/git-browser">← Back to Git Browser</a>
11487 </div>
11488 {% endif %}
11489 <div class="path-scope-grid">
11490 {% if !git_repo.is_empty() %}
11491 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11492 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11493 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11494 {% else %}
11495 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11496 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11497 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11498 {% endif %}
11499 <div class="path-scope-sep"></div>
11500 <div class="scope-legend-row">
11501 <span class="scope-legend-label">Scope legend:</span>
11502 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11503 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11504 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11505 </div>
11506 </div>
11507 {% if git_repo.is_empty() %}
11508 {% if server_mode %}
11509 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11510 ℹ️ Files are compressed and streamed — no fixed size limit.
11511 </div>
11512 {% endif %}
11513 <div class="path-info-row">
11514 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11515 <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>
11516 <span id="project-size-text">Project size: —</span>
11517 </button>
11518 </div>
11519 {% else %}
11520 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11521 {% endif %}
11522 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11523 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11524 </div>
11525
11526 <div class="scope-preview-divider" aria-hidden="true"></div>
11527
11528 <div id="preview-panel">
11529 <div class="preview-error">Loading preview...</div>
11530 </div>
11531 </div>
11532
11533 <div class="section" style="margin-top:14px;">
11534 <div class="preset-inline-row git-inline-row">
11535 <div class="toggle-card" style="margin:0;">
11536 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11537 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11538 <label class="checkbox">
11539 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11540 <div>
11541 <span>Detect and separate git submodules</span>
11542 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11543 </div>
11544 </label>
11545 </div>
11546 <div class="explainer-card prominent" style="margin:0;">
11547 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11548 <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>
11549 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11550 path = libs/core
11551 url = https://github.com/org/core.git
11552
11553[submodule "libs/ui"]
11554 path = libs/ui
11555 url = https://github.com/org/ui.git</div>
11556 </div>
11557 </div>
11558 </div>
11559
11560 <div class="section">
11561 <div class="field-grid">
11562 <div class="field">
11563 <label for="include_globs">Include globs</label>
11564 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
11565 <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>
11566 </div>
11567 <div class="field">
11568 <label for="exclude_globs">Exclude globs</label>
11569 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
11570 <div id="quick-exclude-chips" class="quick-excl-row">
11571 <span class="quick-excl-label">Quick add:</span>
11572 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11573 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11574 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11575 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11576 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11577 <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>
11578 </div>
11579 <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>
11580 </div>
11581 </div>
11582 <div class="glob-guidance-grid">
11583 <div class="glob-guidance-card">
11584 <strong>How to read them</strong>
11585 <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>
11586 </div>
11587 <div class="glob-guidance-card">
11588 <strong>Common include examples</strong>
11589 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11590 </div>
11591 <div class="glob-guidance-card">
11592 <strong>Common exclude examples</strong>
11593 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11594 </div>
11595 </div>
11596 </div>
11597
11598 <div class="section" style="margin-top:14px;">
11599 <div class="preset-inline-row git-inline-row">
11600 <div class="toggle-card" style="margin:0;">
11601 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11602 <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>
11603 <div class="field" style="margin:0;">
11604 <div class="input-group compact">
11605 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11606 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11607 </div>
11608 <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>
11609 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11610 </div>
11611 </div>
11612 <div class="explainer-card prominent" style="margin:0;">
11613 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11614 <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>
11615 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11616lcov --capture --directory . --output-file coverage/lcov.info
11617
11618# C / C++ — llvm-cov (LCOV)
11619llvm-profdata merge -sparse default.profraw -o default.profdata
11620llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11621
11622# C# — coverlet (Cobertura XML)
11623dotnet test --collect:"XPlat Code Coverage"
11624
11625# Python — pytest-cov (Cobertura XML)
11626pytest --cov --cov-report=xml
11627
11628# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11629./gradlew jacocoTestReport</div>
11630 </div>
11631 </div>
11632 </div>
11633
11634 <div class="wizard-actions">
11635 <div class="left"></div>
11636 <div class="right">
11637 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11638 </div>
11639 </div>
11640 </div>
11641
11642 <div class="wizard-step" data-step="2">
11643 <div class="section">
11644 <div class="section-kicker">Step 2</div>
11645 <h2>Choose counting behavior</h2>
11646 <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>
11647 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
11648 <div class="subsection-bar">Primary line classification</div>
11649 <div class="preset-kv-row">
11650 <div class="toggle-card mixed-line-card" style="margin:0;">
11651 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11652 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11653 <select id="mixed_line_policy" name="mixed_line_policy">
11654 <option value="code_only">Code only</option>
11655 <option value="code_and_comment">Code and comment</option>
11656 <option value="comment_only">Comment only</option>
11657 <option value="separate_mixed_category">Separate mixed category</option>
11658 </select>
11659 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11660 </div>
11661 <div class="explainer-card prominent" style="margin:0;">
11662 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11663 <div class="explainer-body" id="mixed-policy-description"></div>
11664 <div class="code-sample" id="mixed-policy-example"></div>
11665 </div>
11666 </div>
11667 </div>
11668
11669 <div class="subsection-bar">Additional scan rules</div>
11670 <div class="scan-rules-grid">
11671 <div class="preset-inline-row">
11672 <div class="toggle-card" style="margin:0;">
11673 <div class="field-help-title">Generated files</div>
11674 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11675 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11676 </div>
11677 <div class="explainer-card prominent" style="margin:0;">
11678 <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>
11679 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
11680# Files matching codegen patterns are excluded:
11681# *.generated.cs *.pb.go *.g.dart</div>
11682 </div>
11683 </div>
11684 <div class="preset-inline-row">
11685 <div class="toggle-card" style="margin:0;">
11686 <div class="field-help-title">Minified files</div>
11687 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
11688 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11689 </div>
11690 <div class="explainer-card prominent" style="margin:0;">
11691 <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>
11692 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
11693# Heuristic: very long lines + low whitespace ratio
11694# jquery.min.js bundle.min.css → skipped</div>
11695 </div>
11696 </div>
11697 <div class="preset-inline-row">
11698 <div class="toggle-card" style="margin:0;">
11699 <div class="field-help-title">Vendor directories</div>
11700 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
11701 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11702 </div>
11703 <div class="explainer-card prominent" style="margin:0;">
11704 <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>
11705 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
11706# Directories named vendor/ node_modules/ third_party/
11707# → entire subtree is excluded from totals</div>
11708 </div>
11709 </div>
11710 <div class="preset-inline-row">
11711 <div class="toggle-card" style="margin:0;">
11712 <div class="field-help-title">Lockfiles and manifests</div>
11713 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
11714 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
11715 </div>
11716 <div class="explainer-card prominent" style="margin:0;">
11717 <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>
11718 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
11719# Files like package-lock.json Cargo.lock yarn.lock
11720# → skipped unless this is enabled</div>
11721 </div>
11722 </div>
11723 <div class="preset-inline-row">
11724 <div class="toggle-card" style="margin:0;">
11725 <div class="field-help-title">Binary handling</div>
11726 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
11727 <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>
11728 </div>
11729 <div class="explainer-card prominent" style="margin:0;">
11730 <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>
11731 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
11732# Detected via long lines + low whitespace heuristic
11733# .png .exe .so → skipped silently</div>
11734 </div>
11735 </div>
11736 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
11737 <div class="toggle-card" style="margin:0;">
11738 <div class="field-help-title">Python docstrings</div>
11739 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
11740 <label class="checkbox">
11741 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
11742 <span>Count as comment-style lines</span>
11743 </label>
11744 </div>
11745 <div class="explainer-card prominent" style="margin:0;">
11746 <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>
11747 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
11748 </div>
11749 </div>
11750 </div>
11751 <div class="subsection-bar">IEEE 1045-1992 counting</div>
11752 <div class="scan-rules-grid">
11753 <div class="preset-inline-row">
11754 <div class="toggle-card" style="margin:0;">
11755 <div class="field-help-title">Continuation lines</div>
11756 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
11757 <select name="continuation_line_policy" id="continuation_line_policy">
11758 <option value="each_physical_line" selected>Each physical line (default)</option>
11759 <option value="collapse_to_logical">Collapse to logical line</option>
11760 </select>
11761 </div>
11762 <div class="explainer-card prominent" style="margin:0;">
11763 <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>
11764 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
11765 ((a) > (b) ? (a) : (b))
11766# each_physical_line → 2 SLOC
11767# collapse_to_logical → 1 SLOC</div>
11768 </div>
11769 </div>
11770 <div class="preset-inline-row">
11771 <div class="toggle-card" style="margin:0;">
11772 <div class="field-help-title">Block-comment blanks</div>
11773 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
11774 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
11775 <option value="count_as_comment" selected>Count as comment (default)</option>
11776 <option value="count_as_blank">Count as blank</option>
11777 </select>
11778 </div>
11779 <div class="explainer-card prominent" style="margin:0;">
11780 <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>
11781 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
11782 * Summary line
11783 * ← blank inside block comment
11784 * Detail line
11785 */
11786# count_as_comment → blank counts toward comments
11787# count_as_blank → blank counts toward blanks</div>
11788 </div>
11789 </div>
11790 <div class="preset-inline-row">
11791 <div class="toggle-card" style="margin:0;">
11792 <div class="field-help-title">Compiler directives</div>
11793 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
11794 <select name="count_compiler_directives" id="count_compiler_directives">
11795 <option value="enabled" selected>Include in code SLOC (default)</option>
11796 <option value="disabled">Exclude from code SLOC</option>
11797 </select>
11798 </div>
11799 <div class="explainer-card prominent" style="margin:0;">
11800 <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>
11801 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
11802#define BUF 256 ← compiler directive
11803int main() { … } ← code
11804# enabled → 3 code SLOC
11805# disabled → 1 code SLOC + 2 directive lines</div>
11806 </div>
11807 </div>
11808 </div>
11809
11810 <div class="always-tracked-tip">
11811 <div class="always-tracked-tip-icon">ℹ</div>
11812 <div class="always-tracked-tip-body">
11813 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
11814 <h4>Comment and blank-line basics & Lines on the boundary</h4>
11815 <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>
11816 </div>
11817 </div>
11818
11819 <div class="wizard-actions">
11820 <div class="left">
11821 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
11822 </div>
11823 <div class="right">
11824 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
11825 </div>
11826 </div>
11827 </div>
11828
11829 <div class="wizard-step" data-step="3">
11830 <div class="section">
11831 <div class="section-kicker">Step 3</div>
11832 <h2>Output and report identity</h2>
11833 <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>
11834 <div class="preset-kv-row">
11835 <div class="toggle-card" style="margin:0;">
11836 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
11837 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
11838 <select id="scan_preset">
11839 <option value="balanced">Balanced local scan</option>
11840 <option value="code_focused">Code focused</option>
11841 <option value="comment_audit">Comment audit</option>
11842 <option value="deep_review">Deep review</option>
11843 </select>
11844 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
11845 </div>
11846 <div class="explainer-card">
11847 <div class="field-help-title">Selected scan preset</div>
11848 <div class="explainer-body" id="scan-preset-description"></div>
11849 <div class="preset-summary-row" id="scan-preset-summary"></div>
11850 <div class="code-sample" id="scan-preset-example"></div>
11851 <div class="preset-note" id="scan-preset-note"></div>
11852 </div>
11853 </div>
11854 <hr class="step3-separator" />
11855 <div class="preset-kv-row">
11856 <div class="toggle-card" style="margin:0;">
11857 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
11858 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
11859 <select id="artifact_preset">
11860 <option value="review">Review bundle</option>
11861 <option value="full">Full bundle</option>
11862 <option value="html_only">HTML only</option>
11863 <option value="machine">Machine bundle</option>
11864 </select>
11865 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
11866 </div>
11867 <div class="explainer-card">
11868 <div class="field-help-title">Selected artifact preset</div>
11869 <div class="explainer-body" id="artifact-preset-description"></div>
11870 <div class="preset-summary-row" id="artifact-preset-summary"></div>
11871 <div class="code-sample" id="artifact-preset-example"></div>
11872 </div>
11873 </div>
11874 </div>
11875
11876 <div class="section section-spacer-top">
11877 <div class="output-field-row">
11878 <div class="field">
11879 <label for="output_dir">Output directory</label>
11880 {% if server_mode %}
11881 <div class="input-group compact">
11882 <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);" />
11883 </div>
11884 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
11885 {% else %}
11886 <div class="input-group compact">
11887 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
11888 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
11889 <button type="button" class="mini-button" id="use-default-output">Use default</button>
11890 </div>
11891 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
11892 {% endif %}
11893 </div>
11894 <div class="output-field-aside">
11895 <strong>Where reports land</strong>
11896 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.
11897 </div>
11898 </div>
11899 </div>
11900
11901 <div class="section section-spacer-top">
11902 <div class="output-field-row">
11903 <div class="field">
11904 <label for="report_title">Report title</label>
11905 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
11906 <div class="hint">Appears in HTML and PDF output headers.</div>
11907 </div>
11908 <div class="output-field-aside">
11909 <strong>Shown in exported artifacts</strong>
11910 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.
11911 </div>
11912 </div>
11913 </div>
11914
11915 <div class="section section-spacer-top">
11916 <div class="output-field-row">
11917 <div class="field">
11918 <label for="report_header_footer">Report header / footer</label>
11919 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
11920 <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>
11921 </div>
11922 <div class="output-field-aside">
11923 <strong>Page-level identification</strong>
11924 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.
11925 </div>
11926 </div>
11927 </div>
11928
11929 <div class="section">
11930 <div class="section-kicker">Artifacts</div>
11931 <div class="artifact-grid" style="margin-bottom:24px;">
11932 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
11933 <div class="marker">✓</div>
11934 <div class="artifact-icon">H</div>
11935 <h4>HTML report</h4>
11936 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
11937 <div class="artifact-tags">
11938 <span class="soft-chip">Best for visual review</span>
11939 <span class="soft-chip">Embeddable preview</span>
11940 </div>
11941 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
11942 </div>
11943 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
11944 <div class="marker">✓</div>
11945 <div class="artifact-icon">P</div>
11946 <h4>PDF export</h4>
11947 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
11948 <div class="artifact-tags">
11949 <span class="soft-chip">Portable snapshot</span>
11950 <span class="soft-chip">Good for handoff</span>
11951 </div>
11952 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
11953 </div>
11954 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
11955 <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>
11956 <div class="marker">✓</div>
11957 <div class="artifact-icon" style="color:var(--muted);">J</div>
11958 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
11959 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
11960 <div class="artifact-tags">
11961 <span class="soft-chip">Required for compare</span>
11962 <span class="soft-chip">Auto-enabled</span>
11963 </div>
11964 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
11965 </div>
11966 </div>
11967 <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>
11968 </div>
11969
11970 <div class="wizard-actions">
11971 <div class="left">
11972 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
11973 </div>
11974 <div class="right">
11975 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
11976 </div>
11977 </div>
11978 </div>
11979
11980 <div class="wizard-step" data-step="4">
11981 <div class="section">
11982 <div class="section-kicker">Step 4</div>
11983 <h2>Review selections and run</h2>
11984 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
11985 <div class="review-grid">
11986 <div class="review-card highlight">
11987 <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>
11988 <ul id="review-scan-summary"></ul>
11989 </div>
11990 <div class="review-card highlight">
11991 <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>
11992 <ul id="review-count-summary"></ul>
11993 </div>
11994 <div class="review-card">
11995 <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>
11996 <ul id="review-artifact-summary"></ul>
11997 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
11998 </div>
11999 <div class="review-card">
12000 <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>
12001 <ul id="review-preview-summary"></ul>
12002 </div>
12003 </div>
12004 </div>
12005
12006 <div class="wizard-actions">
12007 <div class="left">
12008 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12009 </div>
12010 <div class="right">
12011 <button type="submit" id="submit-button" class="primary">Run analysis</button>
12012 </div>
12013 </div>
12014 </div>
12015 {% if server_mode %}
12016 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12017 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12018 {% endif %}
12019 </form>
12020 </div>
12021 </section>
12022 </div>
12023 </div>
12024
12025 <script nonce="{{ csp_nonce }}">
12026 (function () {
12027 function startScanPhase() {
12028 var phaseEl = document.getElementById("scan-phase");
12029 if (!phaseEl) return;
12030 var phases = [
12031 "Discovering files...",
12032 "Decoding file encodings...",
12033 "Detecting languages...",
12034 "Analyzing source lines...",
12035 "Applying counting policies...",
12036 "Aggregating results...",
12037 "Rendering report..."
12038 ];
12039 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12040 var i = 0;
12041 function next() {
12042 phaseEl.style.opacity = "0";
12043 setTimeout(function () {
12044 phaseEl.textContent = phases[i];
12045 phaseEl.style.opacity = "0.85";
12046 var delay = durations[i] || 1800;
12047 i++;
12048 if (i < phases.length) { setTimeout(next, delay); }
12049 }, 200);
12050 }
12051 next();
12052 }
12053
12054 var form = document.getElementById("analyze-form");
12055 var loading = document.getElementById("loading");
12056 var submitButton = document.getElementById("submit-button");
12057 var pathInput = document.getElementById("path");
12058 var GIT_MODE = !!(pathInput && pathInput.readOnly);
12059 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12060 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12061 var outputDirInput = document.getElementById("output_dir");
12062 var reportTitleInput = document.getElementById("report_title");
12063 var previewPanel = document.getElementById("preview-panel");
12064 var refreshButton = document.getElementById("refresh-preview");
12065 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12066 var useSamplePath = document.getElementById("use-sample-path");
12067 var useDefaultOutput = document.getElementById("use-default-output");
12068 var browsePath = document.getElementById("browse-path");
12069 var browseOutputDir = document.getElementById("browse-output-dir");
12070 var browseCoverage = document.getElementById("browse-coverage");
12071 var coverageInput = document.getElementById("coverage_file");
12072 var covScanStatus = document.getElementById("cov-scan-status");
12073 var coverageSuggestTimer = null;
12074 var covAutoFilled = false;
12075 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12076 function fmtBytes(b) {
12077 b = Number(b) || 0;
12078 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12079 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12080 if (b >= 1024) return Math.round(b / 1024) + ' KB';
12081 return b + ' B';
12082 }
12083 var themeToggle = document.getElementById("theme-toggle");
12084
12085 function showBannerToast(msg, isError, opts) {
12086 opts = opts || {};
12087 var t = document.createElement('div');
12088 t.className = isError ? 'toast-error' : 'toast-success';
12089 var topPos = opts.top ? '80px' : null;
12090 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12091 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12092 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12093 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12094 if (opts.icon) {
12095 var inner = document.createElement('span');
12096 inner.innerHTML = opts.icon + ' ';
12097 t.appendChild(inner);
12098 }
12099 t.appendChild(document.createTextNode(msg));
12100 document.body.appendChild(t);
12101 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12102 }
12103 var mixedLinePolicy = document.getElementById("mixed_line_policy");
12104 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12105 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12106 var scanPreset = document.getElementById("scan_preset");
12107 var artifactPreset = document.getElementById("artifact_preset");
12108 var includeGlobsInput = document.getElementById("include_globs");
12109 var excludeGlobsInput = document.getElementById("exclude_globs");
12110
12111 // Quick-exclude chips — append pattern to exclude_globs textarea.
12112 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12113 chip.addEventListener("click", function() {
12114 var pattern = chip.getAttribute("data-pattern") || "";
12115 if (!pattern || !excludeGlobsInput) return;
12116 var current = excludeGlobsInput.value.trim();
12117 // For the "skip all" chip, replace any existing dep patterns cleanly.
12118 var patterns = pattern.split("\n");
12119 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12120 var added = false;
12121 patterns.forEach(function(p) {
12122 p = p.trim();
12123 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12124 });
12125 if (added) {
12126 excludeGlobsInput.value = lines.join("\n");
12127 excludeGlobsInput.dispatchEvent(new Event("input"));
12128 }
12129 chip.classList.add("active");
12130 });
12131 });
12132
12133 var liveReportTitle = document.getElementById("live-report-title");
12134 var navProjectPill = document.getElementById("nav-project-pill");
12135 var navProjectTitle = document.getElementById("nav-project-title");
12136 var reportTitlePreview = null;
12137 var wizardProgressFill = document.getElementById("wizard-progress-fill");
12138 var wizardProgressValue = document.getElementById("wizard-progress-value");
12139 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12140 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12141 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12142 var reportTitleTouched = false;
12143 var currentStep = 1;
12144 var previewTimer = null;
12145 var quickScanBtn = document.getElementById("quick-scan-btn");
12146
12147 function dismissAnalysisModal() {
12148 if (loading) loading.classList.remove("active");
12149 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12150 var el = document.getElementById(id);
12151 if (el) el.classList.add("hidden");
12152 });
12153 var cancelBtn = document.getElementById("lc-cancel-btn");
12154 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12155 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12156 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12157 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12158 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12159 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12160 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12161 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12162 }
12163
12164 var lcDismissBtn = document.getElementById("lc-dismiss");
12165 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12166
12167 function startAsyncAnalysis(formData) {
12168 var gitRepo = (formData.get("git_repo") || "").toString();
12169 var gitRef = (formData.get("git_ref") || "").toString();
12170 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12171 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12172
12173 var pathEl = document.getElementById("lc-path");
12174 if (pathEl) pathEl.textContent = displayPath;
12175
12176 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12177 var el = document.getElementById(id);
12178 if (el) el.classList.add("hidden");
12179 });
12180 var cancelBtn = document.getElementById("lc-cancel-btn");
12181 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12182 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12183 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12184 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12185 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12186 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
12187
12188 if (loading) loading.classList.add("active");
12189
12190 var startTime = Date.now();
12191 var elapsedTimer = setInterval(function() {
12192 var s = Math.floor((Date.now() - startTime) / 1000);
12193 var el = document.getElementById("lc-elapsed");
12194 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12195 }, 1000);
12196
12197 var warnShown = false, pollRetries = 0, activeWaitId = null;
12198
12199 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12200
12201 function lcShowCancelled() {
12202 clearInterval(elapsedTimer);
12203 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12204 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12205 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12206 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12207 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12208 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12209 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12210 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12211 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12212 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12213 }
12214
12215 var lcCancelBtn = document.getElementById("lc-cancel-btn");
12216 if (lcCancelBtn) {
12217 lcCancelBtn.onclick = function() {
12218 if (!activeWaitId) { dismissAnalysisModal(); return; }
12219 lcCancelBtn.disabled = true;
12220 lcCancelBtn.textContent = "Cancelling…";
12221 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12222 .then(function() { lcShowCancelled(); })
12223 .catch(function() { lcShowCancelled(); });
12224 };
12225 }
12226
12227 function lcShowError(msg) {
12228 clearInterval(elapsedTimer);
12229 lcSetPhase("Failed");
12230 var msgEl = document.getElementById("lc-err-msg");
12231 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12232 var errEl = document.getElementById("lc-err");
12233 var actEl = document.getElementById("lc-actions");
12234 if (errEl) errEl.classList.remove("hidden");
12235 if (actEl) actEl.classList.remove("hidden");
12236 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12237 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12238 }
12239
12240 function lcPoll(waitId) {
12241 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12242 .then(function(r) {
12243 if (!r.ok) throw new Error("HTTP " + r.status);
12244 return r.json();
12245 })
12246 .then(function(data) {
12247 pollRetries = 0;
12248 if (data.state === "complete") {
12249 clearInterval(elapsedTimer);
12250 lcSetPhase("Done");
12251 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12252 } else if (data.state === "failed") {
12253 lcShowError(data.message);
12254 } else if (data.state === "cancelled") {
12255 lcShowCancelled();
12256 } else {
12257 var s = Math.floor((Date.now() - startTime) / 1000);
12258 if (s > 90 && !warnShown) {
12259 warnShown = true;
12260 var w = document.getElementById("lc-warn");
12261 if (w) w.classList.remove("hidden");
12262 }
12263 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
12264 setTimeout(function() { lcPoll(waitId); }, 1500);
12265 }
12266 })
12267 .catch(function() {
12268 pollRetries++;
12269 if (pollRetries >= 5) {
12270 lcShowError("Lost connection to server. Reload to check status.");
12271 } else {
12272 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12273 }
12274 });
12275 }
12276
12277 var params = new URLSearchParams(formData);
12278 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12279 .then(function(r) {
12280 var waitId = r.headers.get("x-wait-id");
12281 if (!waitId) { window.location.href = "/scan"; return; }
12282 activeWaitId = waitId;
12283 setTimeout(function() { lcPoll(waitId); }, 1500);
12284 })
12285 .catch(function(err) {
12286 lcShowError("Could not reach server: " + (err.message || err));
12287 });
12288 }
12289
12290 if (quickScanBtn) {
12291 quickScanBtn.addEventListener("click", function () {
12292 var pathVal = pathInput ? pathInput.value.trim() : "";
12293 if (!pathVal) {
12294 alert("Please enter or browse to a project path first.");
12295 return;
12296 }
12297 quickScanBtn.disabled = true;
12298 quickScanBtn.textContent = "Scanning...";
12299 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12300 startAsyncAnalysis(new FormData(form));
12301 });
12302 }
12303
12304 var mixedPolicyInfo = {
12305 code_only: {
12306 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.",
12307 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'
12308 },
12309 code_and_comment: {
12310 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.",
12311 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'
12312 },
12313 comment_only: {
12314 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.",
12315 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'
12316 },
12317 separate_mixed_category: {
12318 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.",
12319 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'
12320 }
12321 };
12322
12323 var scanPresetInfo = {
12324 balanced: {
12325 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.",
12326 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12327 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12328 note: "Best when you want a stable local overview before making deeper adjustments.",
12329 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12330 },
12331 code_focused: {
12332 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12333 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12334 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12335 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12336 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12337 },
12338 comment_audit: {
12339 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12340 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12341 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12342 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12343 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12344 },
12345 deep_review: {
12346 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12347 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12348 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12349 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12350 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12351 }
12352 };
12353
12354 var artifactPresetInfo = {
12355 review: {
12356 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.",
12357 chips: ["HTML", "PDF"],
12358 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12359 },
12360 full: {
12361 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.",
12362 chips: ["HTML", "PDF", "JSON"],
12363 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12364 },
12365 html_only: {
12366 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.",
12367 chips: ["HTML only", "Fast local review"],
12368 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12369 },
12370 machine: {
12371 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12372 chips: ["HTML", "JSON"],
12373 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12374 }
12375 };
12376
12377 function applyTheme(theme) {
12378 if (theme === "dark") document.body.classList.add("dark-theme");
12379 else document.body.classList.remove("dark-theme");
12380 }
12381
12382 function loadSavedTheme() {
12383 var saved = null;
12384 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12385 applyTheme(saved === "dark" ? "dark" : "light");
12386 }
12387
12388 function updateScrollProgress() {
12389 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12390 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12391 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12392 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12393 var step = Math.min(Math.max(currentStep, 1), 4);
12394 var base = stepBase[step];
12395 var end = stepEnd[step];
12396
12397 var scrollFrac = 0;
12398 var activePanel = document.querySelector(".wizard-step.active");
12399 if (activePanel) {
12400 var scrollTop = window.scrollY || window.pageYOffset || 0;
12401 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12402 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12403 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12404 var scrolled = scrollTop + viewH - panelTop;
12405 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12406 }
12407
12408 var percent = Math.round(base + (end - base) * scrollFrac);
12409 percent = Math.min(end, Math.max(base, percent));
12410 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12411 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12412 }
12413
12414 function updateWizardProgress() {
12415 updateScrollProgress();
12416 }
12417
12418 var stepDescriptions = [
12419 "Choose a project folder, apply scope filters, and preview which files will be counted.",
12420 "Configure how mixed code-plus-comment lines and docstrings are classified.",
12421 "Pick your output formats, scan preset, and where reports are saved.",
12422 "Review all settings and launch the analysis."
12423 ];
12424
12425 function updateStepNav(step) {
12426 var infoLabel = document.getElementById("step-nav-info-label");
12427 var infoDesc = document.getElementById("step-nav-info-desc");
12428 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12429 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
12430 }
12431
12432 function updateSidebarSummary() {
12433 var sumPath = document.getElementById("sum-path");
12434 var sumPreset = document.getElementById("sum-preset");
12435 var sumOutput = document.getElementById("sum-output");
12436 var sidebarSummary = document.getElementById("sidebar-summary");
12437 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12438 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
12439 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12440 if (sumPath) sumPath.textContent = pathVal || "—";
12441 if (sumPreset) sumPreset.textContent = presetVal || "—";
12442 if (sumOutput) sumOutput.textContent = outputVal || "—";
12443 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12444 }
12445
12446 function setStep(step, pushHistory) {
12447 currentStep = step;
12448 stepPanels.forEach(function (panel) {
12449 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12450 });
12451 stepButtons.forEach(function (button) {
12452 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12453 });
12454 var layoutEl = document.querySelector(".layout");
12455 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12456 updateWizardProgress();
12457 updateStepNav(step);
12458 stepButtons.forEach(function(btn) {
12459 var t = Number(btn.getAttribute("data-step-target"));
12460 btn.classList.toggle("done", t < step);
12461 });
12462 updateSidebarSummary();
12463
12464 if (pushHistory !== false) {
12465 try {
12466 history.pushState({ wizardStep: step }, "", "#step" + step);
12467 } catch (e) {}
12468 }
12469
12470 window.scrollTo({ top: 0, behavior: "instant" });
12471 }
12472
12473 window.addEventListener("popstate", function (e) {
12474 if (e.state && e.state.wizardStep) {
12475 setStep(e.state.wizardStep, false);
12476 } else {
12477 var hashMatch = location.hash.match(/^#step([1-4])$/);
12478 if (hashMatch) setStep(Number(hashMatch[1]), false);
12479 }
12480 });
12481
12482 function inferTitleFromPath(value) {
12483 if (!value) return "project";
12484 var cleaned = value.replace(/[\/\\]+$/, "");
12485 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12486 return parts.length ? parts[parts.length - 1] : value;
12487 }
12488
12489 function updateReportTitleFromPath() {
12490 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12491 if (!reportTitleTouched) {
12492 reportTitleInput.value = inferred;
12493 }
12494 var title = reportTitleInput.value || inferred;
12495 if (liveReportTitle) liveReportTitle.textContent = title;
12496 if (reportTitlePreview) reportTitlePreview.textContent = title;
12497 document.title = "OxideSLOC | " + title;
12498
12499 var projectPath = (pathInput.value || "").trim();
12500 if (navProjectPill && navProjectTitle) {
12501 if (projectPath.length > 0) {
12502 navProjectTitle.textContent = inferred;
12503 navProjectPill.classList.add("visible");
12504 } else {
12505 navProjectTitle.textContent = "";
12506 navProjectPill.classList.remove("visible");
12507 }
12508 }
12509 }
12510
12511 function updateMixedPolicyUI() {
12512 var key = mixedLinePolicy.value || "code_only";
12513 var info = mixedPolicyInfo[key];
12514 document.getElementById("mixed-policy-description").textContent = info.description;
12515 document.getElementById("mixed-policy-example").textContent = info.example;
12516 }
12517
12518 function updatePythonDocstringUI() {
12519 var checked = !!pythonDocstrings.checked;
12520 document.getElementById("python-docstring-example").textContent = checked
12521 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
12522 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
12523 document.getElementById("python-docstring-live-help").textContent = checked
12524 ? "Enabled: docstrings contribute to comment-style totals."
12525 : "Disabled: docstrings are not counted as comment content.";
12526 }
12527
12528 function renderPresetChips(targetId, chips) {
12529 var target = document.getElementById(targetId);
12530 if (!target) return;
12531 target.innerHTML = (chips || []).map(function (chip) {
12532 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12533 }).join('');
12534 }
12535
12536 function updatePresetDescriptions() {
12537 var scanInfo = scanPresetInfo[scanPreset.value];
12538 var artifactInfo = artifactPresetInfo[artifactPreset.value];
12539 document.getElementById("scan-preset-description").textContent = scanInfo.description;
12540 document.getElementById("scan-preset-example").textContent = scanInfo.example;
12541 document.getElementById("scan-preset-note").textContent = scanInfo.note;
12542 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12543 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12544 renderPresetChips("scan-preset-summary", scanInfo.chips);
12545 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12546 }
12547
12548 function applyScanPreset() {
12549 var info = scanPresetInfo[scanPreset.value];
12550 if (!info || !info.apply) return;
12551 mixedLinePolicy.value = info.apply.mixed;
12552 pythonDocstrings.checked = !!info.apply.docstrings;
12553 document.getElementById("generated_file_detection").value = info.apply.generated;
12554 document.getElementById("minified_file_detection").value = info.apply.minified;
12555 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12556 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12557 document.getElementById("binary_file_behavior").value = info.apply.binary;
12558 updateMixedPolicyUI();
12559 updatePythonDocstringUI();
12560 }
12561
12562 function applyArtifactPreset() {
12563 var enabled = { html: false, pdf: false };
12564 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12565 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12566 if (artifactPreset.value === "html_only") { enabled.html = true; }
12567 if (artifactPreset.value === "machine") { enabled.html = true; }
12568
12569 artifactCards.forEach(function (card) {
12570 var artifact = card.getAttribute("data-artifact");
12571 if (artifact === "json") return;
12572 var checked = !!enabled[artifact];
12573 var checkbox = card.querySelector(".artifact-checkbox");
12574 checkbox.checked = checked;
12575 card.classList.toggle("selected", checked);
12576 });
12577 }
12578
12579 function toggleArtifactCard(card) {
12580 var checkbox = card.querySelector(".artifact-checkbox");
12581 checkbox.checked = !checkbox.checked;
12582 card.classList.toggle("selected", checkbox.checked);
12583 }
12584
12585 function updateReview() {
12586 var scanSummary = document.getElementById("review-scan-summary");
12587 var countSummary = document.getElementById("review-count-summary");
12588 var artifactSummary = document.getElementById("review-artifact-summary");
12589 var outputSummary = document.getElementById("review-output-summary");
12590 var previewSummary = document.getElementById("review-preview-summary");
12591 var readinessSummary = document.getElementById("review-readiness-summary");
12592 var includeText = document.getElementById("include_globs").value.trim();
12593 var excludeText = document.getElementById("exclude_globs").value.trim();
12594 var sidePathPreview = document.getElementById("side-path-preview");
12595 var sideOutputPreview = document.getElementById("side-output-preview");
12596 var sideTitlePreview = document.getElementById("side-title-preview");
12597
12598 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12599 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12600 if (sideTitlePreview) {
12601 var rt = document.getElementById("report_title");
12602 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12603 }
12604
12605 scanSummary.innerHTML = ""
12606 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12607 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12608 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12609
12610 countSummary.innerHTML = ""
12611 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12612 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12613 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12614 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12615 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12616 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12617 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12618 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12619
12620 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12621 artifactSummary.innerHTML = ""
12622 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12623 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12624
12625 outputSummary.innerHTML = ""
12626 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12627 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12628
12629 if (previewSummary) {
12630 if (GIT_MODE) {
12631 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>';
12632 } else {
12633 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12634 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12635 var statMap = {};
12636 statButtons.forEach(function (button) {
12637 var valueNode = button.querySelector('.scope-stat-value');
12638 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12639 });
12640 previewSummary.innerHTML = ''
12641 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12642 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12643 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12644 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12645 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12646 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12647
12648 if (readinessSummary) {
12649 var selectedArtifactsCount = selectedArtifacts.length;
12650 readinessSummary.innerHTML = ''
12651 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12652 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12653 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12654 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12655 }
12656 } // end else (non-GIT_MODE)
12657 }
12658 }
12659
12660 function escapeHtml(value) {
12661 return String(value)
12662 .replace(/&/g, "&")
12663 .replace(/</g, "<")
12664 .replace(/>/g, ">")
12665 .replace(/"/g, """)
12666 .replace(/'/g, "'");
12667 }
12668
12669 function isPythonVisible() {
12670 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
12671 }
12672
12673 function syncPythonVisibility() {
12674 var html = previewPanel.textContent || "";
12675 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
12676 pythonWraps.forEach(function (node) {
12677 node.classList.toggle("hidden", !hasPython);
12678 });
12679 }
12680
12681 function attachPreviewInteractions() {
12682 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
12683 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
12684 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
12685 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
12686 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
12687 var searchInput = previewPanel.querySelector("#explorer-search");
12688 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
12689 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
12690 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
12691 var activeFilter = "all";
12692 var activeLanguage = "";
12693 var searchTerm = "";
12694 var currentSortKey = null;
12695 var currentSortOrder = "asc";
12696 var childRows = {};
12697
12698 rows.forEach(function (row) {
12699 var parentId = row.getAttribute("data-parent-id") || "";
12700 var rowId = row.getAttribute("data-row-id") || "";
12701 if (!childRows[parentId]) childRows[parentId] = [];
12702 childRows[parentId].push(rowId);
12703 });
12704
12705 function rowById(id) {
12706 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
12707 }
12708
12709 function hasCollapsedAncestor(row) {
12710 var parentId = row.getAttribute("data-parent-id");
12711 while (parentId) {
12712 var parent = rowById(parentId);
12713 if (!parent) break;
12714 if (parent.getAttribute("data-expanded") === "false") return true;
12715 parentId = parent.getAttribute("data-parent-id");
12716 }
12717 return false;
12718 }
12719
12720 function updateToggleGlyph(row) {
12721 var toggle = row.querySelector(".tree-toggle");
12722 if (!toggle) return;
12723 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
12724 }
12725
12726 function rowSortValue(row, key) {
12727 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
12728 }
12729
12730 function updateSortButtons() {
12731 sortButtons.forEach(function (button) {
12732 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
12733 var indicator = button.querySelector(".tree-sort-indicator");
12734 button.classList.toggle("active", isActive);
12735 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
12736 if (indicator) {
12737 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
12738 }
12739 });
12740 }
12741
12742 function sortSiblingRows() {
12743 if (!treeContainer) {
12744 updateSortButtons();
12745 return;
12746 }
12747
12748 var rowMap = {};
12749 var childrenMap = {};
12750 rows.forEach(function (row) {
12751 var rowId = row.getAttribute("data-row-id");
12752 var parentId = row.getAttribute("data-parent-id") || "";
12753 rowMap[rowId] = row;
12754 if (!childrenMap[parentId]) childrenMap[parentId] = [];
12755 childrenMap[parentId].push(rowId);
12756 });
12757
12758 Object.keys(childrenMap).forEach(function (parentId) {
12759 if (!parentId) return;
12760 childrenMap[parentId].sort(function (a, b) {
12761 var rowA = rowMap[a];
12762 var rowB = rowMap[b];
12763 if (!currentSortKey) {
12764 return Number(a) - Number(b);
12765 }
12766 var valueA = rowSortValue(rowA, currentSortKey);
12767 var valueB = rowSortValue(rowB, currentSortKey);
12768 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
12769 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
12770 var fallbackA = rowSortValue(rowA, "name");
12771 var fallbackB = rowSortValue(rowB, "name");
12772 if (fallbackA < fallbackB) return -1;
12773 if (fallbackA > fallbackB) return 1;
12774 return Number(a) - Number(b);
12775 });
12776 });
12777
12778 var orderedIds = [];
12779 function pushChildren(parentId) {
12780 (childrenMap[parentId] || []).forEach(function (childId) {
12781 orderedIds.push(childId);
12782 pushChildren(childId);
12783 });
12784 }
12785
12786 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
12787 orderedIds.push(topId);
12788 pushChildren(topId);
12789 });
12790
12791 orderedIds.forEach(function (id) {
12792 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
12793 });
12794 updateSortButtons();
12795 }
12796
12797 function updateLanguageButtons() {
12798 languageButtons.forEach(function (button) {
12799 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
12800 var isActive = languageValue === activeLanguage;
12801 button.classList.toggle("active", isActive);
12802 });
12803 }
12804
12805 function rowSelfMatches(row) {
12806 var kind = row.getAttribute("data-kind");
12807 var status = row.getAttribute("data-status");
12808 var language = (row.getAttribute("data-language") || "").toLowerCase();
12809 var name = row.getAttribute("data-name-lower") || "";
12810 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
12811 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
12812 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
12813 var passesLanguage = !activeLanguage || language === activeLanguage;
12814 return passesFilter && passesSearch && passesLanguage;
12815 }
12816
12817 function hasMatchingDescendant(rowId) {
12818 return (childRows[rowId] || []).some(function (childId) {
12819 var childRow = rowById(childId);
12820 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
12821 });
12822 }
12823
12824 function rowMatches(row) {
12825 if (rowSelfMatches(row)) return true;
12826 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
12827 }
12828
12829 function resetViewState() {
12830 activeFilter = "all";
12831 activeLanguage = "";
12832 searchTerm = "";
12833 currentSortKey = null;
12834 currentSortOrder = "asc";
12835 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12836 if (searchInput) searchInput.value = "";
12837 if (filterSelect) filterSelect.value = "all";
12838 updateLanguageButtons();
12839 }
12840
12841 function applyVisibility() {
12842 rows.forEach(function (row) {
12843 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
12844 row.classList.toggle("hidden-by-filter", !visible);
12845 row.style.display = visible ? "grid" : "none";
12846 });
12847 buttons.forEach(function (button) {
12848 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
12849 });
12850 if (filterSelect) filterSelect.value = activeFilter;
12851 }
12852
12853 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
12854 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
12855 var originalStats = {};
12856 buttons.forEach(function (btn) {
12857 var f = btn.getAttribute('data-filter');
12858 var v = btn.querySelector('.scope-stat-value');
12859 if (f && v) originalStats[f] = v.textContent;
12860 });
12861
12862 function applySubmoduleStats(statsJson) {
12863 try {
12864 var s = JSON.parse(statsJson);
12865 buttons.forEach(function (btn) {
12866 var f = btn.getAttribute('data-filter');
12867 var v = btn.querySelector('.scope-stat-value');
12868 if (!v) return;
12869 if (f === 'dir') v.textContent = s.dirs;
12870 else if (f === 'file') v.textContent = s.files;
12871 else if (f === 'supported') v.textContent = s.supported;
12872 else if (f === 'skipped') v.textContent = s.skipped;
12873 else if (f === 'unsupported') v.textContent = s.unsupported;
12874 });
12875 } catch (e) {}
12876 }
12877
12878 function restoreBaseRepoStats() {
12879 buttons.forEach(function (btn) {
12880 var f = btn.getAttribute('data-filter');
12881 var v = btn.querySelector('.scope-stat-value');
12882 if (v && originalStats[f]) v.textContent = originalStats[f];
12883 });
12884 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12885 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
12886 }
12887
12888 submoduleChips.forEach(function (chip) {
12889 chip.addEventListener('click', function () {
12890 var statsJson = chip.getAttribute('data-sub-stats');
12891 if (!statsJson) return;
12892 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12893 chip.classList.add('active');
12894 applySubmoduleStats(statsJson);
12895 if (baseRepoBtn) baseRepoBtn.style.display = '';
12896 });
12897 });
12898
12899 if (baseRepoBtn) {
12900 baseRepoBtn.addEventListener('click', function () {
12901 restoreBaseRepoStats();
12902 resetViewState();
12903 sortSiblingRows();
12904 applyVisibility();
12905 });
12906 }
12907
12908 buttons.forEach(function (button) {
12909 button.addEventListener("click", function () {
12910 var filterValue = button.getAttribute("data-filter") || "all";
12911 if (filterValue === "reset-view") {
12912 restoreBaseRepoStats();
12913 resetViewState();
12914 sortSiblingRows();
12915 applyVisibility();
12916 return;
12917 }
12918 activeFilter = filterValue;
12919 applyVisibility();
12920 });
12921 });
12922
12923 rows.forEach(function (row) {
12924 updateToggleGlyph(row);
12925 var toggle = row.querySelector(".tree-toggle");
12926 if (toggle) {
12927 toggle.addEventListener("click", function () {
12928 var expanded = row.getAttribute("data-expanded") !== "false";
12929 row.setAttribute("data-expanded", expanded ? "false" : "true");
12930 updateToggleGlyph(row);
12931 applyVisibility();
12932 });
12933 }
12934 });
12935
12936 actionButtons.forEach(function (button) {
12937 button.addEventListener("click", function () {
12938 var action = button.getAttribute("data-explorer-action");
12939 if (action === "expand-all") {
12940 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12941 } else if (action === "collapse-all") {
12942 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
12943 } else if (action === "clear-filters") {
12944 resetViewState();
12945 }
12946 sortSiblingRows();
12947 applyVisibility();
12948 });
12949 });
12950
12951 if (filterSelect) {
12952 filterSelect.addEventListener("change", function () {
12953 activeFilter = filterSelect.value || "all";
12954 applyVisibility();
12955 });
12956 }
12957
12958 languageButtons.forEach(function (button) {
12959 button.addEventListener("click", function () {
12960 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
12961 updateLanguageButtons();
12962 applyVisibility();
12963 });
12964 });
12965
12966 sortButtons.forEach(function (button) {
12967 button.addEventListener("click", function () {
12968 var sortKey = button.getAttribute("data-sort-key");
12969 if (currentSortKey === sortKey) {
12970 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
12971 } else {
12972 currentSortKey = sortKey;
12973 currentSortOrder = "asc";
12974 }
12975 sortSiblingRows();
12976 applyVisibility();
12977 });
12978 });
12979
12980 if (searchInput) {
12981 searchInput.addEventListener("input", function () {
12982 searchTerm = searchInput.value.trim().toLowerCase();
12983 applyVisibility();
12984 });
12985 }
12986
12987 updateLanguageButtons();
12988 sortSiblingRows();
12989 applyVisibility();
12990 }
12991
12992 function loadPreview() {
12993 if (!previewPanel || !pathInput) return;
12994 if (GIT_MODE) {
12995 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>';
12996 return;
12997 }
12998 var path = pathInput.value.trim();
12999 var zeroWarn = document.getElementById('zero-files-warning');
13000 if (!path) {
13001 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13002 if (zeroWarn) zeroWarn.style.display = 'none';
13003 return;
13004 }
13005 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13006 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13007 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
13008 var previewUrl = "/preview?path=" + encodeURIComponent(path)
13009 + "&include_globs=" + encodeURIComponent(includeValue)
13010 + "&exclude_globs=" + encodeURIComponent(excludeValue);
13011 fetch(previewUrl)
13012 .then(function (response) { return response.text(); })
13013 .then(function (html) {
13014 previewPanel.innerHTML = html;
13015 attachPreviewInteractions();
13016 syncPythonVisibility();
13017 updateReview();
13018 setTimeout(collapseLanguagePills, 50);
13019 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13020 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13021 var sizeText = document.getElementById('project-size-text');
13022 var sizeBtn = document.getElementById('project-size-btn');
13023 // In server mode with upload sizes available, keep the compressed/original pair.
13024 if (SERVER_MODE && window._lastUploadSizes) {
13025 var us = window._lastUploadSizes;
13026 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13027 ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13028 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13029 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13030 } else if (sizeText && projectSize) {
13031 sizeText.textContent = 'Project size: ' + projectSize;
13032 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13033 } else if (sizeText) {
13034 sizeText.textContent = 'Project size: —';
13035 }
13036 if (zeroWarn) {
13037 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13038 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13039 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13040 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13041 if (supportedCount === 0 && fileCount > 0) {
13042 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).';
13043 zeroWarn.style.display = '';
13044 } else {
13045 zeroWarn.style.display = 'none';
13046 }
13047 }
13048 })
13049 .catch(function (err) {
13050 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13051 });
13052 }
13053
13054 function pickDirectory(targetInput, kind) {
13055 if (SERVER_MODE) {
13056 if (kind === 'output') {
13057 showBannerToast(
13058 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13059 false,
13060 { top: true, icon: '📁' }
13061 );
13062 return;
13063 }
13064 var inputEl = kind === 'coverage'
13065 ? document.getElementById('cov-upload-input')
13066 : document.getElementById('dir-upload-input');
13067 if (!inputEl) return;
13068 inputEl.onchange = function () {
13069 var files = inputEl.files;
13070 if (!files || files.length === 0) return;
13071 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13072 if (browseBtn) browseBtn.disabled = true;
13073
13074 function fileToBase64(file) {
13075 return new Promise(function (resolve, reject) {
13076 var reader = new FileReader();
13077 reader.onload = function () {
13078 var b64 = reader.result.split(',')[1];
13079 resolve(b64);
13080 };
13081 reader.onerror = reject;
13082 reader.readAsDataURL(file);
13083 });
13084 }
13085
13086 if (kind === 'coverage') {
13087 var f = files[0];
13088 if (previewPanel && targetInput === pathInput)
13089 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13090 fileToBase64(f).then(function (b64) {
13091 return fetch('/api/upload-file', {
13092 method: 'POST',
13093 headers: { 'Content-Type': 'application/json' },
13094 body: JSON.stringify({ filename: f.name, content: b64 })
13095 }).then(function (r) { return r.json(); });
13096 })
13097 .then(function (d) {
13098 if (d && d.tmp_path) {
13099 if (coverageInput) coverageInput.value = d.tmp_path;
13100 setCovStatus('idle');
13101 } else if (d && d.error) { showBannerToast(d.error, true); }
13102 })
13103 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13104 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13105 } else {
13106 // ── Filter to source-code files only ─────────────────────────
13107 // Binary, generated, and dependency files (node_modules, .git,
13108 // build artifacts) are skipped so they are never uploaded.
13109 var CODE_EXTS = new Set([
13110 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13111 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13112 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13113 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13114 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13115 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13116 'tf','hcl','proto','thrift','avsc','graphql','gql'
13117 ]);
13118 var codeFiles = [];
13119 for (var i = 0; i < files.length; i++) {
13120 var f = files[i];
13121 var name = f.name;
13122 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13123 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13124 codeFiles.push(f); continue;
13125 }
13126 var dot = name.lastIndexOf('.');
13127 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13128 }
13129 // Collect specific .git metadata files for server-side git detection.
13130 // These have no source extension so they are excluded by the loop above,
13131 // but the server needs them to read branch/commit/author without running git.
13132 var gitMetaFiles = [];
13133 for (var i = 0; i < files.length; i++) {
13134 var f = files[i];
13135 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13136 var gitIdx = rp.indexOf('/.git/');
13137 if (gitIdx < 0) continue;
13138 var gitRel = rp.slice(gitIdx + 1);
13139 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13140 gitRel === '.git/logs/HEAD' ||
13141 gitRel.startsWith('.git/refs/heads/') ||
13142 gitRel.startsWith('.git/refs/tags/')) {
13143 gitMetaFiles.push(f);
13144 }
13145 }
13146 var uploadFiles = codeFiles.concat(gitMetaFiles);
13147 var total = files.length;
13148 var kept = codeFiles.length;
13149 if (kept === 0) {
13150 if (previewPanel && targetInput === pathInput)
13151 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13152 if (browseBtn) browseBtn.disabled = false;
13153 inputEl.value = '';
13154 return;
13155 }
13156
13157 // ── Helper: apply upload result to UI ────────────────────────
13158 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13159 function applyUploadResult(tmpPath, sizes) {
13160 targetInput.value = tmpPath;
13161 scrollInputToEnd(targetInput);
13162 if (sizes && SERVER_MODE) {
13163 window._lastUploadSizes = sizes;
13164 // Immediately show both sizes before preview loads.
13165 var sizeText = document.getElementById('project-size-text');
13166 var sizeBtn = document.getElementById('project-size-btn');
13167 if (sizeText) {
13168 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13169 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13170 }
13171 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13172 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13173 }
13174 if (targetInput === pathInput) {
13175 updateReportTitleFromPath();
13176 autoSetOutputDir(tmpPath);
13177 fetchProjectHistory(tmpPath);
13178 loadPreview();
13179 suggestCoverageFile(tmpPath);
13180 }
13181 updateReview();
13182 if (browseBtn) browseBtn.disabled = false;
13183 inputEl.value = '';
13184 }
13185
13186 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13187 if (typeof CompressionStream !== 'undefined') {
13188 if (previewPanel && targetInput === pathInput)
13189 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13190
13191 // Build a minimal POSIX ustar tar header for a single file entry.
13192 function buildUstarHeader(filePath, fileSize) {
13193 var BLOCK = 512;
13194 var hdr = new Uint8Array(BLOCK);
13195 var enc = new TextEncoder();
13196 function wStr(off, len, s) {
13197 var b = enc.encode(s);
13198 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13199 }
13200 function wOct(off, len, val) {
13201 var s = val.toString(8);
13202 while (s.length < len - 1) s = '0' + s;
13203 wStr(off, len, s + '\0');
13204 }
13205 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13206 var name = filePath, prefix = '';
13207 if (filePath.length > 99) {
13208 var split = filePath.lastIndexOf('/', 154);
13209 if (split > 0 && filePath.length - split - 1 <= 99) {
13210 prefix = filePath.substring(0, split);
13211 name = filePath.substring(split + 1);
13212 } else { name = filePath.substring(0, 99); }
13213 }
13214 wStr(0, 100, name); // name
13215 wOct(100, 8, 0o000644); // mode
13216 wOct(108, 8, 0); // uid
13217 wOct(116, 8, 0); // gid
13218 wOct(124, 12, fileSize); // size
13219 wOct(136, 12, 0); // mtime (epoch)
13220 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13221 hdr[156] = 48; // type flag '0' = regular file
13222 wStr(157, 100, ''); // linkname
13223 wStr(257, 6, 'ustar'); // magic
13224 wStr(263, 2, '00'); // version
13225 wStr(265, 32, ''); // uname
13226 wStr(297, 32, ''); // gname
13227 wOct(329, 8, 0); // devmajor
13228 wOct(337, 8, 0); // devminor
13229 wStr(345, 155, prefix); // prefix
13230 // Compute checksum (sum of all bytes, placeholder = 32).
13231 var chk = 0;
13232 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13233 var cs = chk.toString(8);
13234 while (cs.length < 6) cs = '0' + cs;
13235 wStr(148, 8, cs + '\0 ');
13236 return hdr;
13237 }
13238
13239 // Build tar.gz one file at a time, piping through CompressionStream.
13240 // RAM usage = compressed output buffer + one file at a time.
13241 (async function () {
13242 try {
13243 var BLOCK = 512;
13244 var cs = new CompressionStream('gzip');
13245 var writer = cs.writable.getWriter();
13246 var chunks = [];
13247 var reader = cs.readable.getReader();
13248 var collecting = (async function () {
13249 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13250 })();
13251
13252 for (var i = 0; i < uploadFiles.length; i++) {
13253 var file = uploadFiles[i];
13254 var path = file.webkitRelativePath || file.name;
13255 var buf = await file.arrayBuffer();
13256 var data = new Uint8Array(buf);
13257 // Header block
13258 await writer.write(buildUstarHeader(path, data.length));
13259 // Data padded to 512-byte boundary
13260 if (data.length > 0) {
13261 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13262 var block = new Uint8Array(padded);
13263 block.set(data);
13264 await writer.write(block);
13265 }
13266 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13267 if (previewPanel && targetInput === pathInput)
13268 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13269 }
13270 }
13271 // End-of-archive: two 512-byte zero blocks
13272 await writer.write(new Uint8Array(BLOCK * 2));
13273 await writer.close();
13274 await collecting;
13275
13276 var blob = new Blob(chunks, { type: 'application/gzip' });
13277 var sizeMB = (blob.size / 1048576).toFixed(1);
13278 if (previewPanel && targetInput === pathInput)
13279 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13280
13281 var resp = await fetch('/api/upload-tarball', {
13282 method: 'POST',
13283 headers: { 'Content-Type': 'application/gzip' },
13284 body: blob
13285 });
13286 var d = await resp.json();
13287 if (d && d.tmp_path) {
13288 applyUploadResult(d.tmp_path, {
13289 compressed_bytes: d.compressed_bytes || 0,
13290 original_bytes: d.original_bytes || 0
13291 });
13292 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13293 } catch (e) {
13294 showBannerToast('Upload failed: ' + String(e), true);
13295 if (browseBtn) browseBtn.disabled = false;
13296 inputEl.value = '';
13297 }
13298 })();
13299
13300 } else {
13301 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13302 // Used only on browsers that lack CompressionStream (pre-2023).
13303 var BATCH = 200;
13304 var batches = [];
13305 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13306 var totalBatches = batches.length;
13307 if (previewPanel && targetInput === pathInput)
13308 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13309
13310 function sendBatch(idx, currentUploadId, lastTmpPath) {
13311 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13312 if (previewPanel && targetInput === pathInput && totalBatches > 1)
13313 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13314 Promise.all(batches[idx].map(function (file) {
13315 return fileToBase64(file).then(function (b64) {
13316 return { path: file.webkitRelativePath || file.name, content: b64 };
13317 });
13318 })).then(function (fileList) {
13319 var body = { files: fileList };
13320 if (currentUploadId) body.upload_id = currentUploadId;
13321 return fetch('/api/upload-directory', {
13322 method: 'POST', headers: { 'Content-Type': 'application/json' },
13323 body: JSON.stringify(body)
13324 }).then(function (r) { return r.json(); });
13325 }).then(function (d) {
13326 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13327 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13328 }).catch(function (e) {
13329 showBannerToast('Upload failed: ' + String(e), true);
13330 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13331 });
13332 }
13333 sendBatch(0, null, '');
13334 }
13335 }
13336 };
13337 inputEl.click();
13338 return;
13339 }
13340
13341 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13342 if (browseButton) browseButton.disabled = true;
13343
13344 if (previewPanel && targetInput === pathInput) {
13345 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13346 }
13347
13348 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
13349 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13350 .then(function (data) {
13351 if (data && data.selected_path) {
13352 targetInput.value = data.selected_path;
13353 scrollInputToEnd(targetInput);
13354
13355 if (targetInput === pathInput) {
13356 updateReportTitleFromPath();
13357 autoSetOutputDir(data.selected_path);
13358 fetchProjectHistory(data.selected_path);
13359 loadPreview();
13360 suggestCoverageFile(data.selected_path);
13361 }
13362
13363 updateReview();
13364 } else if (targetInput === pathInput) {
13365 loadPreview();
13366 }
13367 })
13368 .catch(function () {
13369 window.alert("Directory picker request failed.");
13370 if (previewPanel && targetInput === pathInput) {
13371 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13372 }
13373 })
13374 .finally(function () {
13375 if (browseButton) browseButton.disabled = false;
13376 });
13377 }
13378
13379 if (themeToggle) {
13380 themeToggle.addEventListener("click", function () {
13381 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13382 applyTheme(nextTheme);
13383 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13384 });
13385 }
13386
13387 stepButtons.forEach(function (button) {
13388 button.addEventListener("click", function () {
13389 setStep(Number(button.getAttribute("data-step-target")));
13390 });
13391 });
13392
13393 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13394 button.addEventListener("click", function () {
13395 setStep(Number(button.getAttribute("data-step-target")) || 1);
13396 });
13397 });
13398
13399 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13400 button.addEventListener("click", function () {
13401 updateReview();
13402 setStep(Number(button.getAttribute("data-next")));
13403 });
13404 });
13405
13406 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13407 button.addEventListener("click", function () {
13408 setStep(Number(button.getAttribute("data-prev")));
13409 });
13410 });
13411
13412 document.addEventListener("keydown", function (e) {
13413 var tag = (document.activeElement || {}).tagName || "";
13414 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13415 if (e.altKey || e.ctrlKey || e.metaKey) return;
13416 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13417 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13418 });
13419
13420 if (useSamplePath) {
13421 useSamplePath.addEventListener("click", function () {
13422 pathInput.value = "tests/fixtures/basic";
13423 updateReportTitleFromPath();
13424 autoSetOutputDir("tests/fixtures/basic");
13425 loadPreview();
13426 suggestCoverageFile("tests/fixtures/basic");
13427 });
13428 }
13429
13430 if (useDefaultOutput) {
13431 useDefaultOutput.addEventListener("click", function () {
13432 delete outputDirInput.dataset.userEdited;
13433 autoSetOutputDir(pathInput ? pathInput.value : "");
13434 updateReview();
13435 });
13436 }
13437
13438 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13439 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13440
13441 // ── Drag-and-drop directory upload (server mode only) ─────────────────
13442 // Dropping a folder onto the path field bypasses Chrome's
13443 // "Upload X files to this site?" confirmation dialog.
13444 async function readDirRecursively(dirEntry, basePath) {
13445 var reader = dirEntry.createReader();
13446 var all = [];
13447 for (;;) {
13448 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13449 if (!batch.length) break;
13450 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13451 }
13452 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13453 var out = [];
13454 for (var i = 0; i < all.length; i++) {
13455 var sub = all[i];
13456 if (sub.isFile) {
13457 var f = await new Promise(function(res) { sub.file(res); });
13458 out.push({ file: f, path: basePath + '/' + sub.name });
13459 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13460 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13461 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13462 }
13463 }
13464 return out;
13465 }
13466
13467 function setupPathDropZone() {
13468 if (!SERVER_MODE || !pathInput) return;
13469 var CODE_EXTS = new Set([
13470 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13471 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13472 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13473 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13474 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13475 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13476 ]);
13477 pathInput.addEventListener('dragover', function(e) {
13478 e.preventDefault();
13479 pathInput.classList.add('drag-over');
13480 });
13481 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13482 pathInput.addEventListener('drop', function(e) {
13483 e.preventDefault();
13484 pathInput.classList.remove('drag-over');
13485 var items = e.dataTransfer.items;
13486 if (!items || !items.length) return;
13487 var dirEntry = null;
13488 for (var i = 0; i < items.length; i++) {
13489 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13490 if (entry && entry.isDirectory) { dirEntry = entry; break; }
13491 }
13492 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13493 var btn = browsePath;
13494 if (btn) btn.disabled = true;
13495 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13496
13497 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13498 var total = allEntries.length;
13499 var codeEntries = allEntries.filter(function(e) {
13500 var n = e.file.name;
13501 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13502 var dot = n.lastIndexOf('.');
13503 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13504 });
13505 var kept = codeEntries.length;
13506 if (kept === 0) {
13507 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13508 if (btn) btn.disabled = false; return;
13509 }
13510
13511 function finish(tmpPath, sizes) {
13512 pathInput.value = tmpPath;
13513 scrollInputToEnd(pathInput);
13514 if (sizes) {
13515 window._lastUploadSizes = sizes;
13516 var sizeText = document.getElementById('project-size-text');
13517 var sizeBtn = document.getElementById('project-size-btn');
13518 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13519 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13520 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13521 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13522 }
13523 updateReportTitleFromPath();
13524 autoSetOutputDir(tmpPath);
13525 fetchProjectHistory(tmpPath);
13526 loadPreview();
13527 suggestCoverageFile(tmpPath);
13528 updateReview();
13529 if (btn) btn.disabled = false;
13530 }
13531
13532 if (typeof CompressionStream === 'undefined') {
13533 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13534 if (btn) btn.disabled = false; return;
13535 }
13536
13537 try {
13538 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13539 var BLOCK = 512;
13540 var cs = new CompressionStream('gzip');
13541 var wtr = cs.writable.getWriter();
13542 var chunks = [];
13543 var rdr = cs.readable.getReader();
13544 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13545
13546 function buildHdr(fp, sz) {
13547 var hdr = new Uint8Array(BLOCK);
13548 var enc = new TextEncoder();
13549 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]; }
13550 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13551 var nm = fp, pfx = '';
13552 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); } }
13553 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13554 for (var i = 148; i < 156; i++) hdr[i] = 32;
13555 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);
13556 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13557 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13558 return hdr;
13559 }
13560
13561 for (var i = 0; i < codeEntries.length; i++) {
13562 var ce = codeEntries[i];
13563 var buf = await ce.file.arrayBuffer();
13564 var data = new Uint8Array(buf);
13565 await wtr.write(buildHdr(ce.path, data.length));
13566 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13567 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13568 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13569 }
13570 await wtr.write(new Uint8Array(BLOCK * 2));
13571 await wtr.close();
13572 await collecting;
13573
13574 var blob = new Blob(chunks, { type: 'application/gzip' });
13575 var sizeMB = (blob.size / 1048576).toFixed(1);
13576 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13577 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13578 var d = await resp.json();
13579 if (d && d.tmp_path) {
13580 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13581 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13582 } catch (err) {
13583 showBannerToast('Upload failed: ' + String(err), true);
13584 if (btn) btn.disabled = false;
13585 }
13586 }).catch(function(err) {
13587 showBannerToast('Could not read folder: ' + String(err), true);
13588 if (btn) btn.disabled = false;
13589 });
13590 });
13591 }
13592 setupPathDropZone();
13593 if (browseCoverage) {
13594 browseCoverage.addEventListener("click", function () {
13595 pickDirectory(coverageInput || pathInput, "coverage");
13596 });
13597 }
13598
13599 function setCovStatus(state, opts) {
13600 if (!covScanStatus) return;
13601 opts = opts || {};
13602 covScanStatus.className = "cov-scan-status cov-scan-" + state;
13603 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13604 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>';
13605 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>';
13606 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>';
13607 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>';
13608 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13609 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13610 if (state === "scanning") {
13611 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13612 } else if (state === "found") {
13613 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13614 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13615 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13616 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13617 } else if (state === "hint") {
13618 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13619 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
13620 html += '<div class="cov-scan-sub">Generate one with:</div>';
13621 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13622 } else if (state === "none") {
13623 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13624 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
13625 }
13626 html += '</div></div>';
13627 covScanStatus.innerHTML = html;
13628 if (state === "found") {
13629 var useBtn = covScanStatus.querySelector(".cov-scan-use");
13630 if (useBtn) useBtn.addEventListener("click", function () {
13631 if (coverageInput) coverageInput.value = "";
13632 covAutoFilled = false;
13633 setCovStatus("idle");
13634 });
13635 }
13636 }
13637
13638 function suggestCoverageFile(projectPath) {
13639 if (!coverageInput || !covScanStatus) return;
13640 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13641 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
13642 clearTimeout(coverageSuggestTimer);
13643 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
13644 setCovStatus("scanning");
13645 coverageSuggestTimer = setTimeout(function () {
13646 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
13647 .then(function (r) { return r.json(); })
13648 .then(function (d) {
13649 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13650 if (!d) { setCovStatus("none"); return; }
13651 if (d.found) {
13652 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
13653 setCovStatus("found", { found: d.found, tool: d.tool });
13654 } else if (d.tool && d.hint) {
13655 setCovStatus("hint", { tool: d.tool, hint: d.hint });
13656 } else {
13657 setCovStatus("none");
13658 }
13659 })
13660 .catch(function () { setCovStatus("idle"); });
13661 }, 600);
13662 }
13663
13664 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
13665
13666 if (coverageInput) coverageInput.addEventListener("input", function () {
13667 covAutoFilled = false;
13668 if (!this.value.trim()) setCovStatus("idle");
13669 });
13670
13671 // ── Language pill overflow: collapse to "+N more" chip ─────────────
13672 function collapseLanguagePills() {
13673 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
13674 rows.forEach(function(row) {
13675 // Remove any previous overflow chip
13676 var prev = row.querySelector('.lang-overflow-chip');
13677 if (prev) prev.remove();
13678 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
13679 pills.forEach(function(p) { p.style.display = ''; });
13680 if (!pills.length) return;
13681
13682 // Measure after restoring all pills
13683 var containerRight = row.getBoundingClientRect().right;
13684 var hidden = [];
13685 for (var i = pills.length - 1; i >= 1; i--) {
13686 var rect = pills[i].getBoundingClientRect();
13687 if (rect.right > containerRight + 2) {
13688 hidden.unshift(pills[i]);
13689 pills[i].style.display = 'none';
13690 } else {
13691 break;
13692 }
13693 }
13694
13695 if (hidden.length) {
13696 var chip = document.createElement('button');
13697 chip.type = 'button';
13698 chip.className = 'language-pill lang-overflow-chip';
13699 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
13700 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
13701 row.appendChild(chip);
13702 }
13703 });
13704 }
13705
13706 // Run after preview loads (preview panel populates language pills)
13707 var _origLoadPreviewCb = window.__previewLoaded;
13708 document.addEventListener('previewLoaded', collapseLanguagePills);
13709 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
13710 setTimeout(collapseLanguagePills, 400);
13711
13712 // ── Project history & output dir auto-set ──────────────────────────
13713 var wsOutputRoot = document.getElementById("ws-output-root");
13714 var wsScanCount = document.getElementById("ws-scan-count");
13715 var wsLastScan = document.getElementById("ws-last-scan");
13716 var historyBadge = document.getElementById("path-history-badge");
13717 var historyTimer = null;
13718
13719 var wsOutputLink = document.getElementById("ws-output-link");
13720 function syncStripOutputRoot() {
13721 var val = outputDirInput ? outputDirInput.value : "";
13722 var display = val || "project/sloc";
13723 if (wsOutputRoot) wsOutputRoot.textContent = display;
13724 if (wsOutputLink) wsOutputLink.dataset.folder = val;
13725 }
13726
13727 function scrollInputToEnd(input) {
13728 if (!input) return;
13729 // Defer so the DOM has the new value before we measure scroll width.
13730 requestAnimationFrame(function () {
13731 input.scrollLeft = input.scrollWidth;
13732 input.selectionStart = input.selectionEnd = input.value.length;
13733 });
13734 }
13735
13736 function autoSetOutputDir(projectPath) {
13737 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
13738 if (GIT_MODE && GIT_OUTPUT_DIR) {
13739 outputDirInput.value = GIT_OUTPUT_DIR;
13740 scrollInputToEnd(outputDirInput);
13741 syncStripOutputRoot();
13742 updateReview();
13743 return;
13744 }
13745 if (!projectPath || !projectPath.trim()) return;
13746 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
13747 outputDirInput.value = cleaned + "/sloc";
13748 scrollInputToEnd(outputDirInput);
13749 syncStripOutputRoot();
13750 updateReview();
13751 }
13752
13753 var wsBranch = document.getElementById("ws-branch");
13754
13755 function fetchProjectHistory(projectPath) {
13756 if (!projectPath || !projectPath.trim()) {
13757 if (wsScanCount) wsScanCount.textContent = "—";
13758 if (wsLastScan) wsLastScan.textContent = "—";
13759 if (wsBranch) wsBranch.textContent = "—";
13760 if (historyBadge) historyBadge.style.display = "none";
13761 return;
13762 }
13763 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
13764 .then(function (r) { return r.ok ? r.json() : null; })
13765 .then(function (data) {
13766 if (!data) return;
13767 var countStr = data.scan_count > 0
13768 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
13769 : "never";
13770 var tsStr = data.last_scan_timestamp
13771 ? data.last_scan_timestamp.replace(" UTC","")
13772 : "—";
13773 if (wsScanCount) wsScanCount.textContent = countStr;
13774 if (wsLastScan) wsLastScan.textContent = tsStr;
13775 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
13776 if (data.scan_count > 0) {
13777 if (historyBadge) {
13778 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
13779 historyBadge.textContent = data.scan_count + " previous scan" +
13780 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
13781 "Last: " + (data.last_scan_timestamp || "—") +
13782 " — " + (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.";
13783 historyBadge.className = "path-history-badge found";
13784 historyBadge.style.display = "";
13785 }
13786 } else {
13787 if (historyBadge) historyBadge.style.display = "none";
13788 }
13789 })
13790 .catch(function () {});
13791 }
13792
13793 function onPathChange() {
13794 var val = pathInput ? pathInput.value : "";
13795 // Discard stale upload sizes when the user edits the path manually.
13796 window._lastUploadSizes = null;
13797 updateReportTitleFromPath();
13798 autoSetOutputDir(val);
13799 updateSidebarSummary();
13800 clearTimeout(historyTimer);
13801 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
13802 if (previewTimer) clearTimeout(previewTimer);
13803 previewTimer = setTimeout(loadPreview, 280);
13804 suggestCoverageFile(val);
13805 }
13806
13807 if (pathInput) {
13808 pathInput.addEventListener("input", onPathChange);
13809 }
13810
13811 if (outputDirInput) {
13812 outputDirInput.addEventListener("input", function () {
13813 outputDirInput.dataset.userEdited = "1";
13814 syncStripOutputRoot();
13815 updateReview();
13816 });
13817 }
13818
13819 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
13820 if (!node) return;
13821 node.addEventListener("input", function () {
13822 updateReview();
13823 if (previewTimer) clearTimeout(previewTimer);
13824 previewTimer = setTimeout(loadPreview, 280);
13825 });
13826 });
13827
13828 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
13829 var node = document.getElementById(id);
13830 if (node) node.addEventListener("change", updateReview);
13831 });
13832
13833 if (reportTitleInput) {
13834 reportTitleInput.addEventListener("input", function () {
13835 reportTitleTouched = reportTitleInput.value.trim().length > 0;
13836 updateReportTitleFromPath();
13837 updateReview();
13838 });
13839 }
13840
13841 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
13842 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
13843 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
13844 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
13845
13846 artifactCards.forEach(function (card) {
13847 card.addEventListener("click", function () {
13848 if (card.classList.contains("artifact-locked")) return;
13849 toggleArtifactCard(card);
13850 updateReview();
13851 });
13852 });
13853
13854 if (coverageInput) {
13855 coverageInput.addEventListener("input", function () {
13856 if (coverageInput.value.trim()) setCovStatus("idle");
13857 });
13858 }
13859
13860 if (form && loading && submitButton) {
13861 form.addEventListener("submit", function (e) {
13862 e.preventDefault();
13863 submitButton.disabled = true;
13864 submitButton.textContent = "Scanning...";
13865 startAsyncAnalysis(new FormData(form));
13866 });
13867 }
13868
13869 function openPath(folder) {
13870 if (!folder) return;
13871 fetch('/open-path?path=' + encodeURIComponent(folder))
13872 .then(function (r) { return r.json(); })
13873 .then(function (d) {
13874 if (d && d.server_mode_disabled)
13875 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
13876 })
13877 .catch(function () {});
13878 }
13879
13880 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
13881 btn.addEventListener('click', function () {
13882 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
13883 });
13884 });
13885
13886 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
13887 if (wsOutputLink) {
13888 wsOutputLink.addEventListener('click', function () {
13889 openPath(wsOutputLink.dataset.folder || '');
13890 });
13891 }
13892
13893 loadSavedTheme();
13894 updateMixedPolicyUI();
13895 updatePythonDocstringUI();
13896 applyScanPreset();
13897 updatePresetDescriptions();
13898 applyArtifactPreset();
13899 updateReview();
13900 updateScrollProgress(); // initialise bar to 0% (step 1)
13901 window.addEventListener("scroll", updateScrollProgress, { passive: true });
13902 onPathChange(); // seed output dir, history badge, and preview from initial path
13903 loadPreview();
13904 updateStepNav(1);
13905
13906 // Restore step from URL hash on initial load (e.g., back-forward cache)
13907 (function() {
13908 var hashMatch = location.hash.match(/^#step([1-4])$/);
13909 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
13910 })();
13911
13912 (function randomizeWatermarks() {
13913 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
13914 if (!wms.length) return;
13915 var placed = [];
13916 function tooClose(top, left) {
13917 for (var i = 0; i < placed.length; i++) {
13918 var dt = Math.abs(placed[i][0] - top);
13919 var dl = Math.abs(placed[i][1] - left);
13920 if (dt < 16 && dl < 12) return true;
13921 }
13922 return false;
13923 }
13924 function pick(leftBand) {
13925 for (var attempt = 0; attempt < 50; attempt++) {
13926 var top = Math.random() * 88 + 2;
13927 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13928 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
13929 }
13930 var top = Math.random() * 88 + 2;
13931 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13932 placed.push([top, left]);
13933 return [top, left];
13934 }
13935 var half = Math.floor(wms.length / 2);
13936 wms.forEach(function (img, i) {
13937 var pos = pick(i < half);
13938 var size = Math.floor(Math.random() * 80 + 110);
13939 var rot = (Math.random() * 360).toFixed(1);
13940 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
13941 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;
13942 });
13943 })();
13944
13945 (function spawnCodeParticles() {
13946 var container = document.getElementById('code-particles');
13947 if (!container) return;
13948 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'];
13949 for (var i = 0; i < 38; i++) {
13950 (function(idx) {
13951 var el = document.createElement('span');
13952 el.className = 'code-particle';
13953 el.textContent = snippets[idx % snippets.length];
13954 var left = Math.random() * 94 + 2;
13955 var top = Math.random() * 88 + 6;
13956 var dur = (Math.random() * 10 + 9).toFixed(1);
13957 var delay = (Math.random() * 18).toFixed(1);
13958 var rot = (Math.random() * 26 - 13).toFixed(1);
13959 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13960 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';
13961 container.appendChild(el);
13962 })(i);
13963 }
13964 })();
13965 })();
13966 </script>
13967 <script nonce="{{ csp_nonce }}">
13968 (function () {
13969 var raw = {{ prefill_json|safe }};
13970 if (!raw || typeof raw !== 'object' || !raw.path) return;
13971 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
13972 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
13973 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
13974 setVal('path-input', raw.path || '');
13975 setVal('include-globs', raw.include_globs || '');
13976 setVal('exclude-globs', raw.exclude_globs || '');
13977 setVal('output-dir', raw.output_dir || '');
13978 setVal('report-title', raw.report_title || '');
13979 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
13980 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
13981 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
13982 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
13983 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
13984 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
13985 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
13986 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
13987 setChecked('generate-html', raw.generate_html !== false);
13988 setChecked('generate-pdf', !!raw.generate_pdf);
13989 // Trigger dynamic UI updates after pre-fill.
13990 setTimeout(function () {
13991 var pathEl = document.getElementById('path-input');
13992 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
13993 var policyEl = document.getElementById('mixed-line-policy');
13994 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
13995 }, 80);
13996 })();
13997 </script>
13998 <script nonce="{{ csp_nonce }}">
13999 (function(){
14000 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'}];
14001 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);});}
14002 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14003 function init(){
14004 var btn=document.getElementById('settings-btn');if(!btn)return;
14005 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14006 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>';
14007 document.body.appendChild(m);
14008 var g=document.getElementById('scheme-grid');
14009 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);});
14010 var cl=document.getElementById('settings-close');
14011 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);
14012 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');});
14013 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14014 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14015 }
14016 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14017 }());
14018 </script>
14019 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14020 <div class="wb-ftip-arrow"></div>
14021 <span id="wb-ftip-text"></span>
14022 </div>
14023 <script nonce="{{ csp_nonce }}">(function(){
14024 var tip=document.getElementById('wb-ftip');
14025 var txt=document.getElementById('wb-ftip-text');
14026 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14027 if(!tip||!txt)return;
14028 function pos(el){
14029 var r=el.getBoundingClientRect();
14030 tip.style.display='block';
14031 var tw=tip.offsetWidth;
14032 var lx=r.left+r.width/2-tw/2;
14033 if(lx<8)lx=8;
14034 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14035 tip.style.left=lx+'px';
14036 tip.style.top=(r.bottom+8)+'px';
14037 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';}
14038 }
14039 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14040 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14041 el.addEventListener('mouseleave',function(){tip.style.display='none';});
14042 });
14043 })();
14044 (function(){
14045 function fixArtifactHintSpacing(){
14046 var grid=document.querySelector('.artifact-grid');
14047 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14048 }
14049 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14050 }());
14051 (function(){
14052 var dot=document.getElementById('status-dot');
14053 var pingEl=document.getElementById('server-ping-ms');
14054 var tipEl=document.getElementById('server-tip-ping');
14055 var fm=document.getElementById('footer-mode');
14056 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)';}}
14057 function doPing(){
14058 var t0=performance.now();
14059 fetch('/healthz',{cache:'no-store'})
14060 .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);})
14061 .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)';}});
14062 }
14063 doPing();
14064 setInterval(doPing,5000);
14065 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');}
14066 })();
14067 </script>
14068 <footer class="site-footer">
14069 local code analysis - metrics, history and reports
14070 · <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>
14071 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14072 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14073 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14074 · <a href="/api-docs" rel="noopener">REST API</a>
14075 </footer>
14076</body>
14077</html>
14078"##,
14079 ext = "html"
14080)]
14081struct IndexTemplate {
14082 version: &'static str,
14083 prefill_json: String,
14084 csp_nonce: String,
14085 git_repo: String,
14086 git_ref: String,
14087 git_label_json: String,
14088 git_output_dir_json: String,
14089 server_mode: bool,
14090}
14091
14092#[derive(Template)]
14095#[template(
14096 source = r##"
14097<!doctype html>
14098<html lang="en">
14099<head>
14100 <meta charset="utf-8">
14101 <meta name="viewport" content="width=device-width, initial-scale=1">
14102 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14103 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14104 <style nonce="{{ csp_nonce }}">
14105 :root {
14106 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14107 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14108 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14109 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14110 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14111 }
14112 body.dark-theme {
14113 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14114 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14115 }
14116 *{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;}
14117 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14118 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14119 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14120 .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;}
14121 @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));}}
14122 .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);}
14123 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14124 .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));}
14125 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14126 .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;}
14127 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14128 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14129 @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; } }
14130 .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;}
14131 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14132 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14133 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14134 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14135 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14136 .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;}
14137 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14138 .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);}
14139 .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;}
14140 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14141 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14142 .settings-modal-body{padding:14px 16px 16px;}
14143 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14144 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14145 .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;}
14146 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14147 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14148 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14149 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14150 .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;}
14151 .tz-select:focus{border-color:var(--oxide);}
14152 .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;}
14153 .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;}
14154 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14155 .hero{text-align:center;margin:0 auto 18px;}
14156 .hero-logo-wrap{display:inline-block;cursor:default;}
14157 .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;}
14158 .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;}
14159 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14160 .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;}
14161 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%);}
14162 .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;
14163 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14164 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14165 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;}
14166 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14167 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14168 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;}
14169 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14170 .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;}
14171 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14172 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14173 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14174 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14175 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14176 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14177 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14178 .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;}
14179 .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;}
14180 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14181 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14182 .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);}
14183 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14184 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14185 .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);}
14186 .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);}
14187 .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);}
14188 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14189 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14190 .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;}
14191 body.dark-theme .action-card-cta{color:var(--oxide);}
14192 .action-card.view .action-card-cta{color:var(--accent-2);}
14193 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14194 .action-card.compare .action-card-cta{color:#7c3aed;}
14195 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14196 .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);}
14197 .action-card.git-tools .action-card-cta{color:#15803d;}
14198 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14199 .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);}
14200 .action-card.trend .action-card-cta{color:#0e7490;}
14201 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14202 .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);}
14203 .action-card.automation .action-card-cta{color:#b45309;}
14204 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14205 .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);}
14206 .action-card.test-metrics .action-card-cta{color:#be185d;}
14207 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14208 .action-card:hover .action-card-cta{gap:12px;}
14209 .action-card.card-split{flex-direction:row;align-items:stretch;}
14210 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14211 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14212 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14213 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14214 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14215 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14216 .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;}
14217 .ac-badge.active{opacity:1;}
14218 .ac-badge.github{border-color:#555;color:#555;}
14219 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14220 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14221 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14222 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14223 body.dark-theme .ac-right-row{color:var(--muted);}
14224 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14225 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14226 .divider{height:1px;background:var(--line);margin:32px 0;}
14227 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14228 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14229 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14230 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14231 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14232 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14233 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14234 body.dark-theme .info-chip-val{color:var(--oxide);}
14235 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14236 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14237 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14238 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14239 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14240 border:6px solid transparent;border-top-color:var(--text);}
14241 .info-chip:hover .info-chip-tip{display:block;}
14242 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14243 .chip-slide.fading{filter:blur(5px);opacity:0;}
14244 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14245 .site-footer a{color:var(--muted);}
14246 .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;}
14247 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14248 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14249 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14250 .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;}
14251 .lan-badge.local{background:var(--oxide-2);}
14252 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14253 .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);}
14254 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14255 .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;}
14256 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14257 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14258 .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;}
14259 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14260 .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;}
14261 .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);}
14262 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14263 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14264 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14265 .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;}
14266 @media (max-height: 1100px) {
14267 .page{padding-top:10px;}
14268 .hero{margin-bottom:10px;}
14269 .hero-logo{width:54px;height:60px;}
14270 .hero-logo-shadow{width:42px;}
14271 .hero-title{font-size:28px;}
14272 .hero-subtitle{font-size:13px;}
14273 .card-sections{gap:16px;margin-bottom:10px;}
14274 .card-section-grid-2,.card-section-grid-3{gap:10px;}
14275 .action-card{padding:8px 15px 8px;}
14276 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14277 .action-card-icon svg{width:18px;height:18px;}
14278 .action-card-title{font-size:13px;}
14279 .action-card-desc{font-size:11px;margin-bottom:6px;}
14280 .action-card-cta{font-size:11px;}
14281 .ac-right-row{font-size:11px;}
14282 .divider{margin:14px 0;}
14283 .info-strip{gap:7px;margin-bottom:12px;}
14284 .info-chip{padding:7px 10px;}
14285 .info-chip-val{font-size:13px;}
14286 .info-chip-label{font-size:9px;}
14287 .site-footer{padding:8px 24px;font-size:12px;}
14288 }
14289 @media (max-height: 850px) {
14290 .page{padding-top:6px;}
14291 .hero{margin-bottom:6px;}
14292 .hero-logo{width:42px;height:46px;}
14293 .hero-title{font-size:22px;}
14294 .hero-subtitle{font-size:12px;}
14295 .card-sections{gap:10px;}
14296 .action-card-desc{margin-bottom:4px;}
14297 .divider{margin:8px 0;}
14298 .info-strip{margin-bottom:6px;}
14299 .lan-local-hint{margin-top:10px;}
14300 }
14301 </style>
14302</head>
14303<body>
14304 <div class="background-watermarks" aria-hidden="true">
14305 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14306 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14307 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14308 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14309 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14310 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14311 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14312 </div>
14313 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14314 <div class="top-nav">
14315 <div class="top-nav-inner">
14316 <a class="brand" href="/">
14317 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14318 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14319 </a>
14320 <div class="nav-right">
14321 <a class="nav-pill" href="/">Home</a>
14322 <div class="nav-dropdown">
14323 <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>
14324 <div class="nav-dropdown-menu">
14325 <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>
14326 </div>
14327 </div>
14328 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14329 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14330 <div class="nav-dropdown">
14331 <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>
14332 <div class="nav-dropdown-menu">
14333 <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>
14334 </div>
14335 </div>
14336 <div class="server-status-wrap" id="server-status-wrap">
14337 <div class="nav-pill server-online-pill" id="server-status-pill">
14338 <span class="status-dot" id="status-dot"></span>
14339 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14340 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14341 </div>
14342 <div class="server-status-tip">
14343 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14344 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14345 </div>
14346 </div>
14347 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14348 <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>
14349 </button>
14350 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14351 <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>
14352 <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>
14353 </button>
14354 </div>
14355 </div>
14356 </div>
14357
14358 <div class="page">
14359 <div class="hero">
14360 <div class="hero-logo-wrap" id="hero-logo-wrap">
14361 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14362 </div>
14363 <div class="hero-logo-shadow"></div>
14364 <div class="hero-title-wrap">
14365 <div class="hero-title-aura" aria-hidden="true"></div>
14366 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14367 </div>
14368 <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>
14369 </div>
14370
14371 <div class="card-sections">
14372
14373 <div>
14374 <div class="card-section-label">Analysis</div>
14375 <div class="card-section-grid-2">
14376 <a class="action-card scan card-split" href="/scan-setup">
14377 <div class="action-card-left">
14378 <div class="action-card-icon">
14379 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14380 </div>
14381 <div class="action-card-title">Scan Project</div>
14382 <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>
14383 <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>
14384 </div>
14385 <div class="action-card-sep"></div>
14386 <div class="action-card-right">
14387 <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>
14388 <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>
14389 <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>
14390 <div class="ac-right-stat" id="acp-scan-stat"></div>
14391 </div>
14392 </a>
14393 <a class="action-card test-metrics card-split" href="/test-metrics">
14394 <div class="action-card-left">
14395 <div class="action-card-icon">
14396 <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>
14397 </div>
14398 <div class="action-card-title">Test Metrics</div>
14399 <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>
14400 <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>
14401 </div>
14402 <div class="action-card-sep"></div>
14403 <div class="action-card-right">
14404 <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>
14405 <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>
14406 <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>
14407 <div class="ac-right-stat" id="acp-test-stat"></div>
14408 </div>
14409 </a>
14410 </div>
14411 </div>
14412
14413 <div>
14414 <div class="card-section-label">Reports & Insights</div>
14415 <div class="card-section-grid-3">
14416 <a class="action-card view" href="/view-reports">
14417 <div class="action-card-icon">
14418 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14419 </div>
14420 <div class="action-card-title">View Reports</div>
14421 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14422 <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>
14423 </a>
14424 <a class="action-card compare" href="/compare-scans">
14425 <div class="action-card-icon">
14426 <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>
14427 </div>
14428 <div class="action-card-title">Compare Scans</div>
14429 <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>
14430 <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>
14431 </a>
14432 <a class="action-card trend" href="/trend-reports">
14433 <div class="action-card-icon">
14434 <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>
14435 </div>
14436 <div class="action-card-title">Trend Report</div>
14437 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14438 <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>
14439 </a>
14440 </div>
14441 </div>
14442
14443 <div>
14444 <div class="card-section-label">Developer Tools</div>
14445 <div class="card-section-grid-2">
14446 <a class="action-card git-tools card-split" href="/git-browser">
14447 <div class="action-card-left">
14448 <div class="action-card-icon">
14449 <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>
14450 </div>
14451 <div class="action-card-title">Git Browser</div>
14452 <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>
14453 <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>
14454 </div>
14455 <div class="action-card-sep"></div>
14456 <div class="action-card-right">
14457 <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>
14458 <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>
14459 <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>
14460 </div>
14461 </a>
14462 <a class="action-card automation card-split" href="/integrations">
14463 <div class="action-card-left">
14464 <div class="action-card-icon">
14465 <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>
14466 </div>
14467 <div class="action-card-title">Integrations</div>
14468 <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>
14469 <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>
14470 </div>
14471 <div class="action-card-sep"></div>
14472 <div class="action-card-right">
14473 <div class="ac-badges-grid">
14474 <span class="ac-badge github" id="acp-gh">GitHub</span>
14475 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
14476 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
14477 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14478 </div>
14479 <div class="ac-right-stat" id="acp-int-stat"></div>
14480 </div>
14481 </a>
14482 </div>
14483 </div>
14484
14485 </div>
14486
14487 {% if server_mode %}
14488 <div class="lan-card server">
14489 <div class="lan-card-header">
14490 <span class="lan-badge">LAN server</span>
14491 Accessible on your network
14492 </div>
14493 {% if let Some(ip) = lan_ip %}
14494 <div class="lan-url-row">
14495 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14496 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14497 <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>
14498 Copy URL
14499 </button>
14500 </div>
14501 <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>
14502 {% if has_api_key %}
14503 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
14504 {% endif %}
14505 {% else %}
14506 <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>
14507 {% endif %}
14508 </div>
14509 {% endif %}
14510
14511 <div class="divider"></div>
14512
14513 <div class="info-strip">
14514 <div class="info-chip">
14515 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14516 <div class="chip-slide">
14517 <div class="info-chip-val">41</div>
14518 <div class="info-chip-label">Languages</div>
14519 </div>
14520 </div>
14521 <div class="info-chip">
14522 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14523 <div class="chip-slide">
14524 <div class="info-chip-val">100%</div>
14525 <div class="info-chip-label">Self-contained</div>
14526 </div>
14527 </div>
14528 <div class="info-chip">
14529 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14530 <div class="chip-slide">
14531 <div class="info-chip-val">HTML+PDF</div>
14532 <div class="info-chip-label">Exportable reports</div>
14533 </div>
14534 </div>
14535 <div class="info-chip">
14536 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14537 <div class="chip-slide">
14538 <div class="info-chip-val">Webhook</div>
14539 <div class="info-chip-label">3 platforms</div>
14540 </div>
14541 </div>
14542 <div class="info-chip">
14543 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14544 <div class="chip-slide">
14545 <div class="info-chip-val">IEEE</div>
14546 <div class="info-chip-label">1045-1992</div>
14547 </div>
14548 </div>
14549 </div>
14550
14551 {% if lan_ip.is_none() %}
14552 <div class="lan-local-hint">
14553 <strong>Want teammates on the same network to access this?</strong><br>
14554 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
14555 </div>
14556 {% endif %}
14557 </div>
14558
14559 <footer class="site-footer">
14560 local code analysis - metrics, history and reports
14561 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
14562 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14563 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14564 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14565 · <a href="/api-docs" rel="noopener">REST API</a>
14566 </footer>
14567
14568 <script nonce="{{ csp_nonce }}">
14569 (function () {
14570 var storageKey = 'oxide-sloc-theme';
14571 var body = document.body;
14572 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14573 var toggle = document.getElementById('theme-toggle');
14574 if (toggle) toggle.addEventListener('click', function () {
14575 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14576 body.classList.toggle('dark-theme', next === 'dark');
14577 try { localStorage.setItem(storageKey, next); } catch(e) {}
14578 });
14579 var copyBtn = document.getElementById('lan-copy-btn');
14580 if (copyBtn) copyBtn.addEventListener('click', function() {
14581 var btn = this;
14582 var el = document.getElementById('lan-url-val');
14583 if (!el) return;
14584 var url = el.textContent.trim();
14585 if (navigator.clipboard) {
14586 navigator.clipboard.writeText(url).then(function() {
14587 var orig = btn.innerHTML;
14588 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!';
14589 setTimeout(function() { btn.innerHTML = orig; }, 1800);
14590 });
14591 }
14592 });
14593 (function randomizeWatermarks() {
14594 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14595 if (!wms.length) return;
14596 var placed = [];
14597 function tooClose(top, left) {
14598 for (var i = 0; i < placed.length; i++) {
14599 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14600 if (dt < 16 && dl < 12) return true;
14601 }
14602 return false;
14603 }
14604 function pick(leftBand) {
14605 for (var attempt = 0; attempt < 50; attempt++) {
14606 var top = Math.random() * 88 + 2;
14607 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14608 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14609 }
14610 var top = Math.random() * 88 + 2;
14611 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14612 placed.push([top, left]); return [top, left];
14613 }
14614 var half = Math.floor(wms.length / 2);
14615 wms.forEach(function (img, i) {
14616 var pos = pick(i < half);
14617 var size = Math.floor(Math.random() * 100 + 120);
14618 var rot = (Math.random() * 360).toFixed(1);
14619 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14620 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;
14621 });
14622 })();
14623
14624 (function spawnCodeParticles() {
14625 var container = document.getElementById('code-particles');
14626 if (!container) return;
14627 var snippets = [
14628 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14629 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14630 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14631 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14632 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14633 ];
14634 var count = 38;
14635 for (var i = 0; i < count; i++) {
14636 (function(idx) {
14637 var el = document.createElement('span');
14638 el.className = 'code-particle';
14639 var text = snippets[idx % snippets.length];
14640 el.textContent = text;
14641 var left = Math.random() * 94 + 2;
14642 var top = Math.random() * 88 + 6;
14643 var dur = (Math.random() * 10 + 9).toFixed(1);
14644 var delay = (Math.random() * 18).toFixed(1);
14645 var rot = (Math.random() * 26 - 13).toFixed(1);
14646 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14647 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
14648 + '--rot:' + rot + 'deg;--op:' + op + ';'
14649 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
14650 container.appendChild(el);
14651 })(i);
14652 }
14653 })();
14654 (function heroAnimations() {
14655 var sub = document.getElementById('hero-subtitle');
14656 if (sub) {
14657 var full = sub.textContent.trim();
14658 sub.textContent = '';
14659 sub.style.opacity = '1';
14660 var cursor = document.createElement('span');
14661 cursor.className = 'hero-cursor';
14662 sub.appendChild(cursor);
14663 var i = 0;
14664 setTimeout(function() {
14665 var iv = setInterval(function() {
14666 if (i < full.length) {
14667 sub.insertBefore(document.createTextNode(full[i]), cursor);
14668 i++;
14669 } else {
14670 clearInterval(iv);
14671 setTimeout(function() {
14672 cursor.style.transition = 'opacity 1s ease';
14673 cursor.style.opacity = '0';
14674 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
14675 }, 2400);
14676 }
14677 }, 11);
14678 }, 374);
14679 }
14680 })();
14681 (function logoBob() {
14682 var logo = document.querySelector('.hero-logo');
14683 var shadow = document.querySelector('.hero-logo-shadow');
14684 if (!logo) return;
14685 var cycleStart = null, cycleDur = 3600;
14686 var peakY = -14, peakScale = 1.07, peakRot = 0;
14687 function newCycle() {
14688 cycleDur = 3000 + Math.random() * 1840;
14689 peakY = -(9 + Math.random() * 13.8);
14690 peakScale = 1.04 + Math.random() * 0.081;
14691 peakRot = (Math.random() * 11.5 - 5.75);
14692 }
14693 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
14694 newCycle();
14695 function frame(ts) {
14696 if (cycleStart === null) cycleStart = ts;
14697 var t = (ts - cycleStart) / cycleDur;
14698 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
14699 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
14700 var y = peakY * phase;
14701 var sc = 1 + (peakScale - 1) * phase;
14702 var rot = peakRot * Math.sin(Math.PI * phase);
14703 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
14704 if (shadow) {
14705 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
14706 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
14707 }
14708 requestAnimationFrame(frame);
14709 }
14710 requestAnimationFrame(frame);
14711 })();
14712 (function mouseEffects() {
14713 var heroTitle = document.getElementById('hero-title');
14714 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
14715 function tick() {
14716 raf = null;
14717 if (heroTitle) {
14718 var r = heroTitle.getBoundingClientRect();
14719 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
14720 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
14721 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
14722 }
14723 }
14724 document.addEventListener('mousemove', function(e) {
14725 mx = e.clientX; my = e.clientY;
14726 if (!raf) raf = requestAnimationFrame(tick);
14727 });
14728 document.addEventListener('mouseleave', function() {
14729 if (heroTitle) {
14730 heroTitle.style.transition = 'transform 0.5s ease';
14731 heroTitle.style.transform = '';
14732 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
14733 }
14734 });
14735 document.querySelectorAll('.action-card').forEach(function(card) {
14736 card.addEventListener('mousemove', function(e) {
14737 var rect = card.getBoundingClientRect();
14738 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
14739 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
14740 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
14741 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
14742 });
14743 card.addEventListener('mouseleave', function() {
14744 card.style.transition = '';
14745 card.style.transform = '';
14746 });
14747 });
14748 })();
14749 (function chipSlideshow() {
14750 var slides = [
14751 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
14752 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
14753 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
14754 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
14755 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
14756 ];
14757 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
14758 var indices = [0,0,0,0,0];
14759 var paused = [false,false,false,false,false];
14760 chips.forEach(function(chip, i) {
14761 chip.addEventListener('mouseenter', function() { paused[i] = true; });
14762 chip.addEventListener('mouseleave', function() { paused[i] = false; });
14763 });
14764 function advance(i) {
14765 if (paused[i]) return;
14766 var chip = chips[i];
14767 var inner = chip.querySelector('.chip-slide');
14768 if (!inner) return;
14769 inner.classList.add('fading');
14770 setTimeout(function() {
14771 indices[i] = (indices[i] + 1) % slides[i].length;
14772 var s = slides[i][indices[i]];
14773 chip.querySelector('.info-chip-val').textContent = s.v;
14774 chip.querySelector('.info-chip-label').textContent = s.l;
14775 inner.classList.remove('fading');
14776 }, 720);
14777 }
14778 setInterval(function() {
14779 chips.forEach(function(chip, i) { advance(i); });
14780 }, 6000);
14781 })();
14782 (function cardLiveData() {
14783 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
14784 var el = document.getElementById('acp-scan-stat');
14785 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
14786 }).catch(function(){});
14787 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
14788 var el = document.getElementById('acp-test-stat');
14789 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
14790 }).catch(function(){});
14791 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
14792 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
14793 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
14794 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
14795 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
14796 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
14797 var stat = document.getElementById('acp-int-stat');
14798 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
14799 }).catch(function(){});
14800 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
14801 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
14802 }).catch(function(){});
14803 })();
14804 })();
14805 </script>
14806 <script nonce="{{ csp_nonce }}">
14807 (function(){
14808 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'}];
14809 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);});}
14810 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14811 function init(){
14812 var btn=document.getElementById('settings-btn');if(!btn)return;
14813 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14814 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>';
14815 document.body.appendChild(m);
14816 var g=document.getElementById('scheme-grid');
14817 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);});
14818 var cl=document.getElementById('settings-close');
14819 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);
14820 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');});
14821 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14822 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14823 }
14824 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14825 }());
14826 </script>
14827 <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>
14828</body>
14829</html>
14830"##,
14831 ext = "html"
14832)]
14833struct SplashTemplate {
14834 csp_nonce: String,
14835 server_mode: bool,
14836 lan_ip: Option<String>,
14837 port: u16,
14838 version: &'static str,
14839 has_api_key: bool,
14840}
14841
14842#[derive(Template)]
14845#[template(
14846 source = r##"
14847<!doctype html>
14848<html lang="en">
14849<head>
14850 <meta charset="utf-8">
14851 <meta name="viewport" content="width=device-width, initial-scale=1">
14852 <title>OxideSLOC — Start a Scan</title>
14853 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14854 <style nonce="{{ csp_nonce }}">
14855 :root {
14856 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
14857 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14858 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14859 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14860 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14861 }
14862 body.dark-theme {
14863 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14864 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14865 }
14866 *{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;}
14867 .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);}
14868 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14869 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
14870 .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));}
14871 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
14872 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14873 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14874 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14875 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14876 @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; } }
14877 .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;}
14878 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14879 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14880 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14881 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14882 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14883 .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;}
14884 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14885 .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);}
14886 .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;}
14887 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14888 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14889 .settings-modal-body{padding:14px 16px 16px;}
14890 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14891 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14892 .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;}
14893 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14894 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14895 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14896 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14897 .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;}
14898 .tz-select:focus{border-color:var(--oxide);}
14899 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
14900 .page-header{text-align:center;margin-bottom:16px;}
14901 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
14902 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
14903 /* Cards */
14904 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
14905 .option-card-wrap{position:relative;}
14906 .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;}
14907 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
14908 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14909 .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;}
14910 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
14911 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
14912 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
14913 .card-top-row{display:flex;align-items:center;gap:20px;}
14914 /* Two-column layout inside each card */
14915 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
14916 .card-left{display:flex;align-items:flex-start;min-width:0;}
14917 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
14918 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
14919 .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);}
14920 .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);}
14921 .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);}
14922 .card-text{min-width:0;}
14923 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
14924 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
14925 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
14926 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
14927 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
14928 /* Right CTA column */
14929 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
14930 .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;}
14931 /* Re-scan count badge */
14932 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
14933 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
14934 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
14935 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
14936 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
14937 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
14938 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
14939 body.dark-theme .btn-secondary{color:var(--oxide);}
14940 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
14941 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
14942 /* File input overlay — must be full-width so it aligns with other card-right buttons */
14943 .file-input-wrap{position:relative;width:100%;}
14944 .file-input-wrap .btn{width:100%;}
14945 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
14946 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14947 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14948 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14949 .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;}
14950 @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));}}
14951 /* Recent list (card 3 — full-width section below header) */
14952 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
14953 .recent-list{display:flex;flex-direction:column;gap:8px;}
14954 .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;}
14955 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
14956 .recent-item-info{flex:1;min-width:0;}
14957 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
14958 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
14959 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
14960 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
14961 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14962 .site-footer a{color:var(--muted);}
14963 @media(max-width:680px){
14964 .card-body{grid-template-columns:1fr;}
14965 .card-right{flex-direction:row;flex-wrap:wrap;}
14966 .btn{flex:1;}
14967 }
14968 .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;}
14969 .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;}
14970 .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;}
14971 </style>
14972</head>
14973<body>
14974 <div class="background-watermarks" aria-hidden="true">
14975 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14976 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14977 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14978 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14979 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14980 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14981 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14982 </div>
14983 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14984 <div class="top-nav">
14985 <div class="top-nav-inner">
14986 <a class="brand" href="/">
14987 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14988 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14989 </a>
14990 <div class="nav-right">
14991 <a class="nav-pill" href="/">Home</a>
14992 <div class="nav-dropdown">
14993 <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>
14994 <div class="nav-dropdown-menu">
14995 <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>
14996 </div>
14997 </div>
14998 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14999 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15000 <div class="nav-dropdown">
15001 <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>
15002 <div class="nav-dropdown-menu">
15003 <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>
15004 </div>
15005 </div>
15006 <div class="server-status-wrap" id="server-status-wrap">
15007 <div class="nav-pill server-online-pill" id="server-status-pill">
15008 <span class="status-dot" id="status-dot"></span>
15009 <span id="server-status-label">Server</span>
15010 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15011 </div>
15012 <div class="server-status-tip">
15013 OxideSLOC is running — accessible on your network.
15014 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15015 </div>
15016 </div>
15017 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15018 <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>
15019 </button>
15020 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15021 <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>
15022 <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>
15023 </button>
15024 </div>
15025 </div>
15026 </div>
15027
15028 <div class="page">
15029 <div class="page-header">
15030 <h1>How would you like to scan?</h1>
15031 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15032 </div>
15033
15034 <div class="option-grid">
15035
15036 <!-- Option 1: New scan -->
15037 <div class="option-card-wrap">
15038 <div class="option-card">
15039 <div class="option-icon new-scan">
15040 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15041 </div>
15042 <div class="card-body">
15043 <div class="card-left">
15044 <div class="card-text">
15045 <div class="option-title">Start a new scan</div>
15046 <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>
15047 <ul class="feature-list">
15048 <li>Live project scope preview before you run</li>
15049 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15050 <li>HTML, PDF, and JSON output — your choice</li>
15051 </ul>
15052 </div>
15053 </div>
15054 <div class="card-right">
15055 <a class="btn btn-primary" href="/scan">
15056 Configure & scan
15057 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15058 </a>
15059 <p class="card-tip">Full 4-step setup · all options</p>
15060 </div>
15061 </div>
15062 </div>
15063 </div>
15064
15065 <!-- Option 2: Load from config file -->
15066 <div class="option-card-wrap">
15067 <div class="option-card">
15068 <div class="option-icon load-config">
15069 <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>
15070 </div>
15071 <div class="card-body">
15072 <div class="card-left">
15073 <div class="card-text">
15074 <div class="option-title">Load a saved config</div>
15075 <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>
15076 <ul class="feature-list">
15077 <li>All 15 settings restored from the file</li>
15078 <li>Fully editable — change path or output dir</li>
15079 <li>Works with any scan-config.json</li>
15080 </ul>
15081 </div>
15082 </div>
15083 <div class="card-right">
15084 <div class="file-input-wrap">
15085 <button class="btn btn-secondary" id="load-config-btn" type="button">
15086 <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>
15087 Choose config file
15088 </button>
15089 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15090 </div>
15091 <p class="card-tip" id="config-file-name">Exported after every scan</p>
15092 </div>
15093 </div>
15094 </div>
15095 </div>
15096
15097 <!-- Option 3: Re-scan recent project -->
15098 <div class="option-card-wrap">
15099 <div class="option-card" id="recent-card">
15100 <div class="card-top-row">
15101 <div class="option-icon rescan">
15102 <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>
15103 </div>
15104 <div class="card-body">
15105 <div class="card-left">
15106 <div class="card-text">
15107 <div class="option-title">Re-scan a recent project</div>
15108 <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>
15109 <ul class="feature-list">
15110 <li>All 15+ settings restored from the saved config</li>
15111 <li>Path and output dir are editable before running</li>
15112 <li>Only scans with a saved config appear here</li>
15113 </ul>
15114 </div>
15115 </div>
15116 <div class="card-right">
15117 <div class="rescan-count-box">
15118 <div class="rescan-count-num" id="rescan-count-num">—</div>
15119 <div class="rescan-count-label">saved configs</div>
15120 </div>
15121 <a class="btn btn-secondary" href="/view-reports">
15122 <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>
15123 View all runs
15124 </a>
15125 <p class="card-tip">Opens run history</p>
15126 </div>
15127 </div>
15128 </div>
15129 <div class="section-divider"></div>
15130 <div class="recent-list" id="recent-list">
15131 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15132 </div>
15133 </div>
15134 </div>
15135
15136 </div>
15137 </div>
15138
15139 <footer class="site-footer">
15140 local code analysis - metrics, history and reports
15141 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
15142 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15143 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15144 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15145 · <a href="/api-docs" rel="noopener">REST API</a>
15146 </footer>
15147
15148 <script nonce="{{ csp_nonce }}">
15149 (function () {
15150 var storageKey = 'oxide-sloc-theme';
15151 var body = document.body;
15152 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15153 var toggle = document.getElementById('theme-toggle');
15154 if (toggle) toggle.addEventListener('click', function () {
15155 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15156 body.classList.toggle('dark-theme', next === 'dark');
15157 try { localStorage.setItem(storageKey, next); } catch(e) {}
15158 });
15159
15160 (function randomizeWatermarks() {
15161 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15162 if (!wms.length) return;
15163 var placed = [];
15164 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; }
15165 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]; }
15166 var half = Math.floor(wms.length / 2);
15167 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; });
15168 })();
15169 (function spawnCodeParticles() {
15170 var container = document.getElementById('code-particles');
15171 if (!container) return;
15172 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'];
15173 var count = 38;
15174 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); }
15175 })();
15176 // Recent scans data injected from server
15177 var recentScans = {{ recent_scans_json|safe }};
15178
15179 function configToParams(cfg) {
15180 var p = new URLSearchParams();
15181 p.set('prefilled', '1');
15182 if (cfg.path) p.set('path', cfg.path);
15183 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15184 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15185 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15186 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15187 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15188 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15189 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15190 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15191 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15192 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15193 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15194 if (cfg.report_title) p.set('report_title', cfg.report_title);
15195 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15196 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15197 return p;
15198 }
15199
15200 // Build recent scan list (capped at 3 visible entries)
15201 var list = document.getElementById('recent-list');
15202 var noNote = document.getElementById('no-recent-note');
15203 var hasAny = false;
15204 var MAX_RECENT = 3;
15205 if (Array.isArray(recentScans)) {
15206 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15207 var shown = 0;
15208 validEntries.forEach(function (entry) {
15209 if (shown >= MAX_RECENT) return;
15210 shown++;
15211 hasAny = true;
15212 var item = document.createElement('div');
15213 item.className = 'recent-item';
15214 item.title = 'Restore all settings and open wizard';
15215 item.innerHTML =
15216 '<div class="recent-item-info">' +
15217 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15218 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
15219 '</div>' +
15220 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15221 item.addEventListener('click', function () {
15222 var params = configToParams(entry.config);
15223 window.location.href = '/scan?' + params.toString();
15224 });
15225 list.appendChild(item);
15226 });
15227 if (validEntries.length > MAX_RECENT) {
15228 var moreEl = document.createElement('div');
15229 moreEl.className = 'recent-more-link';
15230 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
15231 list.appendChild(moreEl);
15232 }
15233 }
15234 if (hasAny && noNote) noNote.style.display = 'none';
15235 // Update count badge
15236 var countEl = document.getElementById('rescan-count-num');
15237 if (countEl) {
15238 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15239 countEl.textContent = total > 0 ? total : '0';
15240 }
15241
15242 // Config file loader
15243 var fileInput = document.getElementById('config-file-input');
15244 var fileName = document.getElementById('config-file-name');
15245 if (fileInput) {
15246 fileInput.addEventListener('change', function () {
15247 var file = fileInput.files && fileInput.files[0];
15248 if (!file) return;
15249 if (fileName) fileName.textContent = '✓ ' + file.name;
15250 var reader = new FileReader();
15251 reader.onload = function (e) {
15252 try {
15253 var cfg = JSON.parse(e.target.result);
15254 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15255 var params = configToParams(cfg);
15256 window.location.href = '/scan?' + params.toString();
15257 } catch (err) {
15258 alert('Could not parse config file: ' + err.message);
15259 }
15260 };
15261 reader.readAsText(file);
15262 });
15263 }
15264
15265 function escHtml(s) {
15266 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
15267 }
15268 })();
15269 </script>
15270 <script nonce="{{ csp_nonce }}">
15271 (function(){
15272 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'}];
15273 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);});}
15274 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15275 function init(){
15276 var btn=document.getElementById('settings-btn');if(!btn)return;
15277 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15278 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>';
15279 document.body.appendChild(m);
15280 var g=document.getElementById('scheme-grid');
15281 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);});
15282 var cl=document.getElementById('settings-close');
15283 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);
15284 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');});
15285 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15286 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15287 }
15288 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15289 }());
15290 </script>
15291 <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>
15292</body>
15293</html>
15294"##,
15295 ext = "html"
15296)]
15297struct ScanSetupTemplate {
15298 version: &'static str,
15299 recent_scans_json: String,
15300 csp_nonce: String,
15301}
15302
15303#[derive(Template)]
15304#[template(
15305 source = r##"
15306<!doctype html>
15307<html lang="en">
15308<head>
15309 <meta charset="utf-8">
15310 <meta name="viewport" content="width=device-width, initial-scale=1">
15311 <title>OxideSLOC | {{ report_title }} | Report</title>
15312 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15313 <style nonce="{{ csp_nonce }}">
15314 :root {
15315 --radius: 18px;
15316 --bg: #f5efe8;
15317 --surface: rgba(255,255,255,0.82);
15318 --surface-2: #fbf7f2;
15319 --surface-3: #efe6dc;
15320 --line: #e6d0bf;
15321 --line-strong: #dcb89f;
15322 --text: #43342d;
15323 --muted: #7b675b;
15324 --muted-2: #a08777;
15325 --nav: #b85d33;
15326 --nav-2: #7a371b;
15327 --accent: #6f9bff;
15328 --accent-2: #4a78ee;
15329 --oxide: #d37a4c;
15330 --oxide-2: #b35428;
15331 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15332 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15333 --success-bg: #e8f5ed;
15334 --success-text: #1a8f47;
15335 --info-bg: #eef3ff;
15336 --info-text: #4467d8;
15337 }
15338
15339 body.dark-theme {
15340 --bg: #1b1511;
15341 --surface: #261c17;
15342 --surface-2: #2d221d;
15343 --surface-3: #372922;
15344 --line: #524238;
15345 --line-strong: #6c5649;
15346 --text: #f5ece6;
15347 --muted: #c7b7aa;
15348 --muted-2: #aa9485;
15349 --nav: #b85d33;
15350 --nav-2: #7a371b;
15351 --accent: #6f9bff;
15352 --accent-2: #4a78ee;
15353 --oxide: #d37a4c;
15354 --oxide-2: #b35428;
15355 --shadow: 0 18px 42px rgba(0,0,0,0.28);
15356 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15357 --success-bg: #163927;
15358 --success-text: #8fe2a8;
15359 --info-bg: #1c2847;
15360 --info-text: #a9c1ff;
15361 }
15362
15363 * { box-sizing: border-box; }
15364 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); }
15365 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15366 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15367 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15368 .top-nav, .page { position: relative; z-index: 2; }
15369 .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); }
15370 .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; }
15371 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15372 .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)); }
15373 .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; }
15374 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15375 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15376 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15377 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15378 .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; }
15379 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15380 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15381 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15382 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15383 @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; } }
15384 .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; }
15385 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15386 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15387 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15388 .theme-toggle .icon-sun { display:none; }
15389 body.dark-theme .theme-toggle .icon-sun { display:block; }
15390 body.dark-theme .theme-toggle .icon-moon { display:none; }
15391 .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;}
15392 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15393 .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);}
15394 .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;}
15395 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15396 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15397 .settings-modal-body{padding:14px 16px 16px;}
15398 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15399 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15400 .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;}
15401 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15402 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15403 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15404 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15405 .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;}
15406 .tz-select:focus{border-color:var(--oxide);}
15407 .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; }
15408 .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;}
15409 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; }
15410 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15411 .hero, .panel { padding: 22px; }
15412 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15413 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15414 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15415 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15416 .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; }
15417 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15418 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15419 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15420 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15421 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15422 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15423 .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; }
15424 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15425 .delta-card-val { font-size:16px; font-weight:800; }
15426 .delta-card-val.pos { color:#1e7e34; }
15427 .delta-card-val.neg { color:var(--neg); }
15428 .delta-card-val.mod { color:#b35428; }
15429 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15430 .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; }
15431 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15432 .delta-card-inline:hover .delta-card-tip { opacity:1; }
15433 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15434 .compare-ts { font-size:13px; color:var(--muted); }
15435 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15436 .compare-arrow { color: var(--muted); }
15437 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15438 .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; }
15439 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15440 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15441 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15442 .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:flex-start; gap:6px; }
15443 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15444 .run-mgmt-card .action-buttons { justify-content:flex-start; }
15445 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; }
15446 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15447 .button, .copy-button {
15448 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;
15449 }
15450 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15451 @keyframes spin { to { transform: rotate(360deg); } }
15452 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15453 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15454 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15455 .path-item strong { display: block; margin-bottom: 6px; }
15456 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15457 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15458 .path-subitem { flex: 1; }
15459 .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); }
15460 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); }
15461 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15462 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15463 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15464 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15465 th { color: var(--muted); font-weight: 700; }
15466 tr:last-child td { border-bottom: none; }
15467 #subm-tbl col:nth-child(1){width:15%;}
15468 #subm-tbl col:nth-child(2){width:31%;}
15469 #subm-tbl col:nth-child(3){width:9%;}
15470 #subm-tbl col:nth-child(4){width:9%;}
15471 #subm-tbl col:nth-child(5){width:9%;}
15472 #subm-tbl col:nth-child(6){width:9%;}
15473 #subm-tbl col:nth-child(7){width:9%;}
15474 #subm-tbl col:nth-child(8){width:9%;}
15475 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15476 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15477 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15478 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15479 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15480 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15481 .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; }
15482 .soft-chip.success { gap:7px; padding:0 16px 0 12px; background:linear-gradient(135deg,rgba(26,143,71,0.12),rgba(26,143,71,0.06)); color:var(--success-text); border:1.5px solid rgba(26,143,71,0.35); box-shadow:0 0 0 4px rgba(26,143,71,0.07),0 2px 8px rgba(26,143,71,0.12); font-size:12px; letter-spacing:0.02em; }
15483 .soft-chip.success svg { flex:0 0 auto; }
15484 body.dark-theme .soft-chip.success { background:linear-gradient(135deg,rgba(143,226,168,0.12),rgba(143,226,168,0.05)); border-color:rgba(143,226,168,0.3); box-shadow:0 0 0 4px rgba(143,226,168,0.07),0 2px 8px rgba(0,0,0,0.2); }
15485 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15486 .muted { color: var(--muted); }
15487 /* Run-ID chip row (mirrors HTML report) */
15488 .run-id-row { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
15489 .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; flex:1 1 180px; max-width:320px; }
15490 .run-id-chip[data-copy] { cursor:pointer; }
15491 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15492 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15493 .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; }
15494 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15495 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15496 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15497 .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; }
15498 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15499 .run-id-chip:hover .chip-tooltip { opacity:1; }
15500 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15501 .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; }
15502 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15503 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15504 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15505 /* Meta chips row */
15506 .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); }
15507 .meta-chip { display:inline-flex; align-items:center; gap:5px; padding:0 14px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
15508 .meta-chip:first-child { padding-left:0; }
15509 .meta-chip:last-child { border-right:none; }
15510 .meta-chip b { color:var(--text); font-weight:700; }
15511 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15512 .site-footer a{color:var(--muted);}
15513 .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; }
15514 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15515 .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; }
15516 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15517 /* Stat chips (matches HTML report) */
15518 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15519 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15520 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15521 .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; }
15522 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15523 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15524 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15525 .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; }
15526 .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; }
15527 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15528 .stat-chip:hover .stat-chip-tip { opacity:1; }
15529 /* Submodule panel */
15530 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15531 /* Metrics tables stack */
15532 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15533 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15534 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15535 .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)); }
15536 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15537 /* Metrics table */
15538 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15539 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15540 .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; }
15541 .metrics-table thead th:not(:first-child) { text-align: right; }
15542 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15543 .metrics-table tbody tr:last-child td { border-bottom: none; }
15544 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15545 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15546 .metrics-table tbody tr:hover td { background: var(--surface-2); }
15547 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15548 .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; }
15549 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15550 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15551 .mt-val-pos { color: var(--pos); font-weight: 700; }
15552 .mt-val-neg { color: var(--neg); font-weight: 700; }
15553 .mt-val-zero { color: var(--muted); }
15554 .mt-val-mod { color: var(--oxide-2); }
15555 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15556 @media (max-width: 1180px) {
15557 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15558 .nav-project-slot, .nav-status { justify-content:flex-start; }
15559 .hero-top { flex-direction: column; }
15560 .run-mgmt-strip { flex-direction: column; }
15561 }
15562 .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;}
15563 @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));}}
15564 .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;}
15565 /* ── Result-page chart controls ─────────────────────────────────────────── */
15566 .r-chart-section{margin-bottom:24px;}
15567 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15568 .section-pair > .panel{flex-shrink:0;}
15569 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15570 .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;}
15571 .r-chart-select:focus{border-color:var(--accent);}
15572 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15573 .r-chart-container svg{display:block;width:100%;height:auto;}
15574 .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;}
15575 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15576 .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;}
15577 .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);}
15578 .r-chart-modal-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0 0 16px;display:block;}
15579 .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;}
15580 .r-chart-modal-close:hover{opacity:.7;}
15581 body.dark-theme .r-chart-modal{background:var(--surface);}
15582 .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15583 .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
15584 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15585 .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;}
15586 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15587 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15588 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15589 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15590 #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:9999;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
15591 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15592 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15593 .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;}
15594 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15595 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15596 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
15597 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15598 .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%;}
15599 .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%;}
15600 body.has-report-banner .top-nav{top:27px;}
15601 body.has-report-banner{padding-bottom:27px;}
15602 </style>
15603</head>
15604<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15605 <div class="background-watermarks" aria-hidden="true">
15606 <img src="/images/logo/logo-text.png" alt="" />
15607 <img src="/images/logo/logo-text.png" alt="" />
15608 <img src="/images/logo/logo-text.png" alt="" />
15609 <img src="/images/logo/logo-text.png" alt="" />
15610 <img src="/images/logo/logo-text.png" alt="" />
15611 <img src="/images/logo/logo-text.png" alt="" />
15612 <img src="/images/logo/logo-text.png" alt="" />
15613 <img src="/images/logo/logo-text.png" alt="" />
15614 <img src="/images/logo/logo-text.png" alt="" />
15615 <img src="/images/logo/logo-text.png" alt="" />
15616 <img src="/images/logo/logo-text.png" alt="" />
15617 <img src="/images/logo/logo-text.png" alt="" />
15618 <img src="/images/logo/logo-text.png" alt="" />
15619 <img src="/images/logo/logo-text.png" alt="" />
15620 </div>
15621 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15622 {% if let Some(banner) = report_header_footer %}
15623 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15624 {% endif %}
15625 <div class="top-nav">
15626 <div class="top-nav-inner">
15627 <a class="brand" href="/">
15628 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15629 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15630 </a>
15631 <div class="nav-project-slot">
15632 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
15633 </div>
15634 <div class="nav-status">
15635 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
15636 <div class="nav-dropdown">
15637 <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>
15638 <div class="nav-dropdown-menu">
15639 <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>
15640 </div>
15641 </div>
15642 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
15643 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15644 <div class="nav-dropdown">
15645 <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>
15646 <div class="nav-dropdown-menu">
15647 <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>
15648 </div>
15649 </div>
15650 <div class="server-status-wrap" id="server-status-wrap">
15651 <div class="nav-pill server-online-pill" id="server-status-pill">
15652 <span class="status-dot" id="status-dot"></span>
15653 <span id="server-status-label">Server</span>
15654 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15655 </div>
15656 <div class="server-status-tip">
15657 OxideSLOC is running — accessible on your network.
15658 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15659 </div>
15660 </div>
15661 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15662 <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>
15663 </button>
15664 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
15665 <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>
15666 <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>
15667 </button>
15668 </div>
15669 </div>
15670 </div>
15671
15672 <div class="page">
15673 <section class="hero">
15674 <div class="hero-top">
15675 <div>
15676 <div class="soft-chip success"><svg width="13" height="13" 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>
15677 <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
15678 <h1 class="hero-title">{{ report_title }}</h1>
15679 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
15680 </div>
15681 </div>
15682 <div class="hero-quick-actions">
15683 {% if server_mode %}
15684 <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>
15685 {% else %}
15686 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
15687 {% endif %}
15688 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
15689 {% if !server_mode %}
15690 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
15691 {% endif %}
15692 </div>
15693 </div>
15694
15695 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
15696 <div class="run-id-row">
15697 <span class="run-id-chip" data-copy="{{ run_id }}" style="max-width:none;flex:2 1 300px;">
15698 <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>
15699 <span class="run-id-chip-value">{{ run_id }}</span>
15700 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
15701 </span>
15702 {% match git_commit_long %}
15703 {% when Some with (long_sha) %}
15704 <span class="run-id-chip" data-copy="{{ long_sha }}">
15705 <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>
15706 <span class="run-id-chip-value">{{ long_sha }}</span>
15707 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
15708 </span>
15709 {% when None %}
15710 <span class="run-id-chip muted-chip">
15711 <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>
15712 <span class="run-id-chip-value">Not detected</span>
15713 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
15714 </span>
15715 {% endmatch %}
15716 {% match git_branch %}
15717 {% when Some with (branch) %}
15718 <span class="run-id-chip">
15719 <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>
15720 <span class="run-id-chip-value">{{ branch }}</span>
15721 <span class="chip-tooltip">Git branch active at scan time</span>
15722 </span>
15723 {% when None %}
15724 <span class="run-id-chip muted-chip">
15725 <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>
15726 <span class="run-id-chip-value">Not detected</span>
15727 <span class="chip-tooltip">No Git branch was found for this scan</span>
15728 </span>
15729 {% endmatch %}
15730 {% match git_author %}
15731 {% when Some with (author) %}
15732 <span class="run-id-chip">
15733 <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>
15734 <span class="run-id-chip-value">{{ author }}</span>
15735 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
15736 </span>
15737 {% when None %}
15738 <span class="run-id-chip muted-chip">
15739 <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>
15740 <span class="run-id-chip-value">Not detected</span>
15741 <span class="chip-tooltip">No commit author was found for this scan</span>
15742 </span>
15743 {% endmatch %}
15744 </div>
15745
15746 <!-- Scan metadata row -->
15747 <div class="meta">
15748 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
15749 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
15750 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
15751 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
15752 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
15753 </div>
15754
15755 <!-- 12 summary stat chips -->
15756 <div class="summary-strip">
15757 <div class="stat-chip" data-raw="{{ physical_lines }}">
15758 <div class="stat-chip-label">Physical lines</div>
15759 <div class="stat-chip-val">{{ physical_lines }}</div>
15760 <div class="stat-chip-exact"></div>
15761 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
15762 </div>
15763 <div class="stat-chip" data-raw="{{ code_lines }}">
15764 <div class="stat-chip-label">Code</div>
15765 <div class="stat-chip-val">{{ code_lines }}</div>
15766 <div class="stat-chip-exact"></div>
15767 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
15768 </div>
15769 <div class="stat-chip" data-raw="{{ comment_lines }}">
15770 <div class="stat-chip-label">Comments</div>
15771 <div class="stat-chip-val">{{ comment_lines }}</div>
15772 <div class="stat-chip-exact"></div>
15773 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
15774 </div>
15775 <div class="stat-chip" data-raw="{{ blank_lines }}">
15776 <div class="stat-chip-label">Blank</div>
15777 <div class="stat-chip-val">{{ blank_lines }}</div>
15778 <div class="stat-chip-exact"></div>
15779 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
15780 </div>
15781 <div class="stat-chip" data-raw="{{ mixed_lines }}">
15782 <div class="stat-chip-label">Mixed separate</div>
15783 <div class="stat-chip-val">{{ mixed_lines }}</div>
15784 <div class="stat-chip-exact"></div>
15785 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
15786 </div>
15787 <div class="stat-chip" data-raw="{{ functions }}">
15788 <div class="stat-chip-label">Functions</div>
15789 <div class="stat-chip-val">{{ functions }}</div>
15790 <div class="stat-chip-exact"></div>
15791 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
15792 </div>
15793 <div class="stat-chip" data-raw="{{ classes }}">
15794 <div class="stat-chip-label">Classes / Types</div>
15795 <div class="stat-chip-val">{{ classes }}</div>
15796 <div class="stat-chip-exact"></div>
15797 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
15798 </div>
15799 <div class="stat-chip" data-raw="{{ variables }}">
15800 <div class="stat-chip-label">Variables</div>
15801 <div class="stat-chip-val">{{ variables }}</div>
15802 <div class="stat-chip-exact"></div>
15803 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
15804 </div>
15805 <div class="stat-chip" data-raw="{{ imports }}">
15806 <div class="stat-chip-label">Imports</div>
15807 <div class="stat-chip-val">{{ imports }}</div>
15808 <div class="stat-chip-exact"></div>
15809 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
15810 </div>
15811 <div class="stat-chip" data-raw="{{ test_count }}">
15812 <div class="stat-chip-label">Tests</div>
15813 <div class="stat-chip-val">{{ test_count }}</div>
15814 <div class="stat-chip-exact"></div>
15815 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
15816 </div>
15817 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
15818 <div class="stat-chip-label">Code density</div>
15819 <div class="stat-chip-val stat-chip-density-val">—</div>
15820 <div class="stat-chip-exact"></div>
15821 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
15822 </div>
15823 <div class="stat-chip" data-raw="{{ files_analyzed }}">
15824 <div class="stat-chip-label">Files analyzed</div>
15825 <div class="stat-chip-val">{{ files_analyzed }}</div>
15826 <div class="stat-chip-exact"></div>
15827 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
15828 </div>
15829 </div>
15830
15831 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
15832 <div class="compare-banner">
15833 <div class="compare-banner-body">
15834 <div class="compare-banner-meta">
15835 <span class="compare-label">Previous scan</span>
15836 <span class="compare-ts">{{ prev_ts }}</span>
15837 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
15838 {% if let Some(prev_code) = prev_run_code_lines %}
15839 <div class="compare-banner-stats" style="margin-top:4px;">
15840 <span>Code before: <strong>{{ prev_code }}</strong></span>
15841 <span class="compare-arrow">→</span>
15842 <span>Code now: <strong>{{ code_lines }}</strong></span>
15843 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
15844 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
15845 </div>
15846 {% endif %}
15847 </div>
15848 {% if delta_lines_added.is_some() %}
15849 <div class="delta-cards-inline">
15850 <div class="delta-card-inline">
15851 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
15852 <div class="delta-card-lbl">lines added</div>
15853 <div class="delta-card-tip">Code lines added since the previous scan</div>
15854 </div>
15855 <div class="delta-card-inline">
15856 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
15857 <div class="delta-card-lbl">lines removed</div>
15858 <div class="delta-card-tip">Code lines removed since the previous scan</div>
15859 </div>
15860 <div class="delta-card-inline">
15861 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
15862 <div class="delta-card-lbl">unmodified lines</div>
15863 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
15864 </div>
15865 <div class="delta-card-inline">
15866 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
15867 <div class="delta-card-lbl">files modified</div>
15868 <div class="delta-card-tip">Files with at least one line changed</div>
15869 </div>
15870 <div class="delta-card-inline">
15871 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
15872 <div class="delta-card-lbl">files added</div>
15873 <div class="delta-card-tip">New files added since the previous scan</div>
15874 </div>
15875 <div class="delta-card-inline">
15876 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
15877 <div class="delta-card-lbl">files removed</div>
15878 <div class="delta-card-tip">Files deleted since the previous scan</div>
15879 </div>
15880 <div class="delta-card-inline">
15881 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
15882 <div class="delta-card-lbl">files unchanged</div>
15883 <div class="delta-card-tip">Files with no changes since the previous scan</div>
15884 </div>
15885 </div>
15886 {% else %}
15887 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
15888 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
15889 </p>
15890 {% endif %}
15891 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
15892 </div>
15893 </div>
15894 {% endif %}{% endif %}
15895
15896 <div class="action-grid">
15897 <div class="action-card">
15898 <h3>HTML report</h3>
15899 <div class="action-buttons">
15900 {% match html_url %}
15901 {% when Some with (url) %}
15902 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
15903 {% when None %}{% endmatch %}
15904 {% match html_download_url %}
15905 {% when Some with (url) %}
15906 <a class="button secondary" href="{{ url }}">Download HTML</a>
15907 {% when None %}{% endmatch %}
15908 {% match html_path %}
15909 {% when Some with (_path) %}{% when None %}{% endmatch %}
15910 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
15911 </div>
15912 </div>
15913 <div class="action-card">
15914 <h3>PDF report</h3>
15915 <div class="action-buttons">
15916 {% match pdf_url %}
15917 {% when Some with (url) %}
15918 {% if pdf_generating %}
15919 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
15920 <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>
15921 Generating PDF…
15922 </button>
15923 {% else %}
15924 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
15925 {% endif %}
15926 {% when None %}
15927 {% match html_url %}
15928 {% when Some with (hurl) %}
15929 <a class="button" href="{{ hurl }}?autoprint=1" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
15930 <p class="action-empty-note" style="margin-top:6px;font-size:11px;">
15931 No PDF renderer found on the server. Opens the HTML report in your browser
15932 with the print dialog ready — choose <strong>Save as PDF</strong>.
15933 </p>
15934 {% when None %}
15935 <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;">
15936 PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
15937 </p>
15938 {% endmatch %}
15939 {% endmatch %}
15940 {% match pdf_download_url %}
15941 {% when Some with (url) %}
15942 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
15943 {% when None %}{% endmatch %}
15944 {% match pdf_url %}
15945 {% when Some with (_) %}
15946 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
15947 {% when None %}{% endmatch %}
15948 </div>
15949 </div>
15950 <div class="action-card">
15951 <h3>JSON result</h3>
15952 <div class="action-buttons">
15953 {% match json_url %}
15954 {% when Some with (url) %}
15955 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
15956 {% when None %}{% endmatch %}
15957 {% match json_download_url %}
15958 {% when Some with (url) %}
15959 <a class="button secondary" href="{{ url }}">Download JSON</a>
15960 {% when None %}{% endmatch %}
15961 {% match json_path %}
15962 {% when Some with (_path) %}
15963 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
15964 {% when None %}
15965 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
15966 {% endmatch %}
15967 </div>
15968 </div>
15969 <div class="action-card">
15970 <h3>Scan config</h3>
15971 <div class="action-buttons">
15972 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
15973 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
15974 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
15975 </div>
15976 </div>
15977 {% if confluence_configured %}
15978 <div class="action-card" id="confluenceCard">
15979 <h3>Confluence</h3>
15980 <div class="action-buttons">
15981 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
15982 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
15983 </div>
15984 <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>
15985 </div>
15986 {% endif %}
15987 </div>
15988 <div class="run-mgmt-strip">
15989 <div class="run-mgmt-card">
15990 <h3>Download bundle</h3>
15991 <div class="action-buttons">
15992 <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
15993 </div>
15994 <p class="action-empty-note">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
15995 </div>
15996 <div class="run-mgmt-card" id="delete-run-card">
15997 <h3>Delete run</h3>
15998 <div class="action-buttons">
15999 <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16000 </div>
16001 <p class="action-empty-note">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16002 </div>
16003 </div>
16004 {% if confluence_configured %}
16005 <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;">
16006 <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);">
16007 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16008 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16009 <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;">
16010 <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>
16011 <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;">
16012 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16013 <div style="display:flex;gap:10px;justify-content:flex-end;">
16014 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16015 <button class="button" id="confSubmitBtn" type="button">Post</button>
16016 </div>
16017 </div>
16018 </div>
16019 {% endif %}
16020 <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;">
16021 <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);">
16022 <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16023 <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>
16024 <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16025 <div style="display:flex;gap:10px;justify-content:flex-end;">
16026 <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16027 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16028 </div>
16029 </div>
16030 </div>
16031 {% if !submodule_rows.is_empty() %}
16032 <div class="submodule-panel">
16033 <div class="toolbar-row">
16034 <div>
16035 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16036 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16037 </div>
16038 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16039 </div>
16040 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16041 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16042 <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>
16043 <thead>
16044 <tr>
16045 <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>
16046 <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>
16047 <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>
16048 <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>
16049 <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>
16050 <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>
16051 <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>
16052 <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>
16053 </tr>
16054 </thead>
16055 <tbody>
16056 {% for row in submodule_rows %}
16057 <tr>
16058 <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>
16059 <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>
16060 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16061 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16062 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16063 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16064 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16065 <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>
16066 </tr>
16067 {% endfor %}
16068 </tbody>
16069 </table>
16070 </div>
16071 </div>
16072 {% endif %}
16073
16074 <div class="metrics-tables-stack">
16075
16076 <div class="metrics-table-wrap">
16077 <div class="metrics-table-title">Files</div>
16078 <table class="metrics-table">
16079 <thead>
16080 <tr>
16081 <th>Metric</th>
16082 <th>This Run</th>
16083 <th>Previous</th>
16084 <th>Change</th>
16085 </tr>
16086 </thead>
16087 <tbody>
16088 <tr>
16089 <td>Files analyzed</td>
16090 <td class="mt-val-large">{{ files_analyzed }}</td>
16091 <td>{{ prev_fa_str }}</td>
16092 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16093 </tr>
16094 <tr>
16095 <td>Files skipped</td>
16096 <td>{{ files_skipped }}</td>
16097 <td>{{ prev_fs_str }}</td>
16098 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16099 </tr>
16100 <tr>
16101 <td>Files modified</td>
16102 <td class="mt-val-na">—</td>
16103 <td class="mt-val-na">—</td>
16104 <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>
16105 </tr>
16106 <tr>
16107 <td>Files unchanged</td>
16108 <td class="mt-val-na">—</td>
16109 <td class="mt-val-na">—</td>
16110 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16111 </tr>
16112 </tbody>
16113 </table>
16114 </div>
16115
16116 <div class="metrics-table-wrap">
16117 <div class="metrics-table-title">Line Counts</div>
16118 <table class="metrics-table">
16119 <thead>
16120 <tr>
16121 <th>Metric</th>
16122 <th>This Run</th>
16123 <th>Previous</th>
16124 <th>Change</th>
16125 </tr>
16126 </thead>
16127 <tbody>
16128 <tr>
16129 <td>Physical lines</td>
16130 <td class="mt-val-large">{{ physical_lines }}</td>
16131 <td>{{ prev_pl_str }}</td>
16132 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16133 </tr>
16134 <tr>
16135 <td>Code lines</td>
16136 <td class="mt-val-large">{{ code_lines }}</td>
16137 <td>{{ prev_cl_str }}</td>
16138 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16139 </tr>
16140 <tr>
16141 <td>Comment lines</td>
16142 <td>{{ comment_lines }}</td>
16143 <td>{{ prev_cml_str }}</td>
16144 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16145 </tr>
16146 <tr>
16147 <td>Blank lines</td>
16148 <td>{{ blank_lines }}</td>
16149 <td>{{ prev_bl_str }}</td>
16150 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16151 </tr>
16152 <tr>
16153 <td>Mixed (separate)</td>
16154 <td>{{ mixed_lines }}</td>
16155 <td class="mt-val-na">—</td>
16156 <td class="mt-val-na">—</td>
16157 </tr>
16158 </tbody>
16159 </table>
16160 </div>
16161
16162 <div class="metrics-tables-lower">
16163 <div class="metrics-table-wrap">
16164 <div class="metrics-table-title">Code Structure</div>
16165 <table class="metrics-table">
16166 <thead>
16167 <tr>
16168 <th>Metric</th>
16169 <th>This Run</th>
16170 </tr>
16171 </thead>
16172 <tbody>
16173 <tr>
16174 <td>Functions</td>
16175 <td>{{ functions }}</td>
16176 </tr>
16177 <tr>
16178 <td>Classes / Types</td>
16179 <td>{{ classes }}</td>
16180 </tr>
16181 <tr>
16182 <td>Variables</td>
16183 <td>{{ variables }}</td>
16184 </tr>
16185 <tr>
16186 <td>Imports</td>
16187 <td>{{ imports }}</td>
16188 </tr>
16189 </tbody>
16190 </table>
16191 </div>
16192
16193 <div class="metrics-table-wrap">
16194 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16195 <table class="metrics-table">
16196 <thead>
16197 <tr>
16198 <th>Metric</th>
16199 <th>Change</th>
16200 </tr>
16201 </thead>
16202 <tbody>
16203 <tr>
16204 <td>Lines added</td>
16205 <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>
16206 </tr>
16207 <tr>
16208 <td>Lines removed</td>
16209 <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>
16210 </tr>
16211 <tr>
16212 <td>Lines modified (net)</td>
16213 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16214 </tr>
16215 <tr>
16216 <td>Lines unmodified</td>
16217 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16218 </tr>
16219 </tbody>
16220 </table>
16221 </div>
16222 </div>
16223
16224 </div>
16225
16226 <div class="path-list">
16227 <div class="path-item">
16228 <div class="path-item-label">Project path</div>
16229 <code>{{ project_path }}</code>
16230 </div>
16231 <div class="path-item">
16232 <div class="path-item-label">Git branch</div>
16233 {% if let Some(branch) = git_branch %}
16234 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16235 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16236 {% else %}
16237 <code style="color:var(--muted)">—</code>
16238 {% endif %}
16239 </div>
16240 <div class="path-item">
16241 <div class="path-item-label">Output folder</div>
16242 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16243 </div>
16244 <div class="path-item">
16245 <div class="path-item-label">Run ID</div>
16246 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16247 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16248 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16249 </div>
16250 </div>
16251 </div>
16252 </section>
16253
16254 <div id="r-tt" aria-hidden="true"></div>
16255
16256 <div class="section-pair">
16257 <section class="panel">
16258 <div class="toolbar-row">
16259 <div>
16260 <h2>Language breakdown</h2>
16261 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16262 </div>
16263 </div>
16264 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16265 </section>
16266
16267 <section class="panel r-chart-section">
16268 <div class="toolbar-row" style="margin-bottom:16px;">
16269 <div>
16270 <h2>Visualizations</h2>
16271 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16272 </div>
16273 </div>
16274
16275 <div class="r-viz-grid">
16276 <div class="r-viz-card">
16277 <p class="r-viz-card-title">Language Composition</p>
16278 <div class="r-chart-tab-bar">
16279 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16280 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16281 </div>
16282 <div class="r-chart-container" id="r-composition-chart"></div>
16283 </div>
16284 <div class="r-viz-card">
16285 <p class="r-viz-card-title">Files vs Code Lines</p>
16286 <div class="r-chart-container" id="r-scatter-chart"></div>
16287 </div>
16288 {% if has_semantic_data %}
16289 <div class="r-viz-card">
16290 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16291 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16292 <select class="r-chart-select" id="r-semantic-metric">
16293 <option value="functions">Functions</option>
16294 <option value="classes">Classes</option>
16295 <option value="variables">Variables</option>
16296 <option value="imports">Imports</option>
16297 </select>
16298 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16299 </div>
16300 <div class="r-chart-container" id="r-semantic-chart"></div>
16301 </div>
16302 {% endif %}
16303 <div class="r-viz-card">
16304 <p class="r-viz-card-title">Comment Density</p>
16305 <div class="r-chart-container" id="r-density-chart"></div>
16306 </div>
16307 {% if has_submodule_data %}
16308 <div class="r-viz-card">
16309 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16310 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
16311 <select class="r-chart-select" id="r-sub-metric">
16312 <option value="code">Code Lines</option>
16313 <option value="comment">Comments</option>
16314 <option value="blank">Blank Lines</option>
16315 <option value="physical">Physical Lines</option>
16316 <option value="files">Files</option>
16317 </select>
16318 <select class="r-chart-select" id="r-sub-sort">
16319 <option value="desc">Value ↓</option>
16320 <option value="asc">Value ↑</option>
16321 <option value="name">Name A→Z</option>
16322 </select>
16323 </div>
16324 <div class="r-chart-container" id="r-submodule-chart"></div>
16325 </div>
16326 {% endif %}
16327 </div>
16328
16329 </section>
16330 </div>
16331
16332 </div>
16333
16334 <script nonce="{{ csp_nonce }}">
16335 (function () {
16336 var body = document.body;
16337 var themeToggle = document.getElementById('theme-toggle');
16338 var storageKey = 'oxide-sloc-theme';
16339
16340 function applyTheme(theme) {
16341 body.classList.toggle('dark-theme', theme === 'dark');
16342 }
16343
16344 function loadSavedTheme() {
16345 try {
16346 var saved = localStorage.getItem(storageKey);
16347 if (saved === 'dark' || saved === 'light') {
16348 applyTheme(saved);
16349 }
16350 } catch (e) {}
16351 }
16352
16353 if (themeToggle) {
16354 themeToggle.addEventListener('click', function () {
16355 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16356 applyTheme(nextTheme);
16357 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16358 });
16359 }
16360
16361 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16362 button.addEventListener('click', function () {
16363 var value = button.getAttribute('data-copy-value') || '';
16364 if (!value) return;
16365 var originalText = button.textContent;
16366 function flashSuccess() {
16367 button.textContent = 'Copied!';
16368 setTimeout(function () { button.textContent = originalText; }, 1800);
16369 }
16370 function flashFail() {
16371 button.textContent = 'Copy failed';
16372 setTimeout(function () { button.textContent = originalText; }, 2000);
16373 }
16374 if (navigator.clipboard && navigator.clipboard.writeText) {
16375 navigator.clipboard.writeText(value).then(flashSuccess, function () {
16376 fallbackCopy(value, flashSuccess, flashFail);
16377 });
16378 } else {
16379 fallbackCopy(value, flashSuccess, flashFail);
16380 }
16381 });
16382 });
16383 function fallbackCopy(text, onSuccess, onFail) {
16384 try {
16385 var ta = document.createElement('textarea');
16386 ta.value = text;
16387 ta.style.position = 'fixed';
16388 ta.style.top = '-9999px';
16389 ta.style.left = '-9999px';
16390 document.body.appendChild(ta);
16391 ta.focus();
16392 ta.select();
16393 var ok = document.execCommand('copy');
16394 document.body.removeChild(ta);
16395 if (ok) { onSuccess(); } else { onFail(); }
16396 } catch (e) { onFail(); }
16397 }
16398
16399 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16400 btn.addEventListener('click', function () {
16401 var folder = btn.getAttribute('data-folder') || '';
16402 if (!folder) return;
16403 fetch('/open-path?path=' + encodeURIComponent(folder))
16404 .then(function (r) { return r.json(); })
16405 .then(function (d) {
16406 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16407 })
16408 .catch(function () {});
16409 });
16410 });
16411
16412 loadSavedTheme();
16413
16414 // ── Compact number formatting for stat chips ──────────────────────────
16415 (function(){
16416 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();}
16417 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16418 var raw=parseInt(chip.getAttribute('data-raw'),10);
16419 if(isNaN(raw))return;
16420 var valEl=chip.querySelector('.stat-chip-val');
16421 if(valEl)valEl.textContent=fmt(raw);
16422 var exactEl=chip.querySelector('.stat-chip-exact');
16423 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16424 });
16425 // Code density chip
16426 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16427 var code=parseInt(chip.getAttribute('data-code'),10);
16428 var phys=parseInt(chip.getAttribute('data-physical'),10);
16429 if(isNaN(code)||isNaN(phys)||phys===0)return;
16430 var pct=(code/phys*100).toFixed(1)+'%';
16431 var valEl=chip.querySelector('.stat-chip-val');
16432 if(valEl)valEl.textContent=pct;
16433 });
16434 // Click-to-copy on run-id-chip elements
16435 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16436 chip.addEventListener('click',function(){
16437 var val=chip.getAttribute('data-copy');
16438 if(!val)return;
16439 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16440 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);}
16441 chip.classList.add('chip-copied-flash');
16442 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16443 });
16444 });
16445 })();
16446
16447 // ── Shared tooltip for all result-page charts ─────────────────────────
16448 var rTT=(function(){
16449 var el=document.getElementById('r-tt');
16450 if(!el)return{s:function(){},h:function(){},m:function(){}};
16451 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16452 function hide(){el.style.display='none';}
16453 function move(e){
16454 var x=e.clientX+16,y=e.clientY-12;
16455 var r=el.getBoundingClientRect();
16456 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16457 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16458 el.style.left=x+'px';el.style.top=y+'px';
16459 }
16460 return{s:show,h:hide,m:move};
16461 })();
16462 window.rTT=rTT;
16463
16464 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16465 (function(){
16466 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16467 document.addEventListener('mouseover',function(e){
16468 var t=e.target;
16469 while(t&&t.getAttribute){
16470 var l=t.getAttribute('data-ttl');
16471 if(l!==null){
16472 var v=t.getAttribute('data-ttv')||'';
16473 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16474 return;
16475 }
16476 t=t.parentNode;
16477 }
16478 });
16479 document.addEventListener('mouseout',function(e){
16480 var t=e.target;
16481 while(t&&t.getAttribute){
16482 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16483 t=t.parentNode;
16484 }
16485 });
16486 document.addEventListener('mousemove',function(e){
16487 var el=document.getElementById('r-tt');
16488 if(el&&el.style.display!=='none')rTT.m(e);
16489 });
16490 })();
16491
16492 // ── Language overview charts ───────────────────────────────────────────
16493 (function(){
16494 var D={{ lang_chart_json|safe }};
16495 if(!D||!D.length)return;
16496 var el=document.getElementById('result-lang-charts');
16497 if(!el)return;
16498 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16499 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16500 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16501 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();}
16502 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16503 function px(n){return Math.round(n);}
16504 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+'"';}
16505 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16506
16507 // Donut chart — height matches the stacked-bar chart so both panels align
16508 var rHb_d=28;
16509 var DH=Math.max(220,D.length*rHb_d+32);
16510 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16511 var legX=204,DW=360;
16512 var legCount=D.length;
16513 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16514 var legYStart=Math.round((DH-legCount*legSpacing)/2);
16515 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">';
16516 if(D.length===1){
16517 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16518 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+'"/>';
16519 } else {
16520 var ang=-Math.PI/2;
16521 D.forEach(function(d,i){
16522 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16523 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16524 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16525 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16526 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16527 var pct=Math.round(d.code/tot*100);
16528 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"/>';
16529 ang+=sw;
16530 });
16531 }
16532 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16533 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16534 D.forEach(function(d,i){
16535 var ly=legYStart+i*legSpacing;
16536 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16537 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16538 });
16539 ds+='</svg>';
16540
16541 // Horizontal stacked-bar chart — fills container width
16542 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16543 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16544 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">';
16545 D.forEach(function(d,i){
16546 var y=6+i*rHb,x=LW;
16547 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16548 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>';
16549 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;
16550 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;
16551 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"/>';
16552 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>';
16553 });
16554 var ly=SH-14;
16555 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>';
16556 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>';
16557 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>';
16558 bs+='</svg>';
16559 el.innerHTML='<div class="r-lang-overview">'+
16560 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16561 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16562 '</div>';
16563 })();
16564
16565 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16566 (function(){
16567 var LANG_D={{ lang_chart_json|safe }};
16568 var SCAT_D={{ scatter_chart_json|safe }};
16569 var SEM_D={{ semantic_chart_json|safe }};
16570 var SUB_D={{ submodule_chart_json|safe }};
16571 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16572 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16573 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();}
16574 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16575 function px(n){return Math.round(n);}
16576 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+'"';}
16577
16578 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16579 function renderComposition(mode){
16580 var el=document.getElementById('r-composition-chart');
16581 if(!el||!LANG_D||!LANG_D.length)return;
16582 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16583 var LW=110,SH=224;
16584 var svgW=Math.max(320,el.offsetWidth||480);
16585 var BW=Math.max(120,svgW-LW-80);
16586 var legendH=24,topPad=4;
16587 var n=LANG_D.length||1;
16588 var rowTotal=Math.floor((SH-legendH-topPad)/n);
16589 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16590 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">';
16591 if(mode==='pct'){
16592 LANG_D.forEach(function(d,i){
16593 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16594 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16595 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16596 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>';
16597 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;
16598 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;
16599 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+'"/>';
16600 var pct=Math.round((d.code||0)/tot2*100);
16601 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16602 });
16603 } else {
16604 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
16605 LANG_D.forEach(function(d,i){
16606 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
16607 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16608 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>';
16609 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;
16610 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;
16611 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+'"/>';
16612 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>';
16613 });
16614 }
16615 var ly=SH-legendH+4;
16616 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>';
16617 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>';
16618 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>';
16619 s+='</svg>';
16620 el.innerHTML=s;
16621 }
16622 renderComposition('abs');
16623 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
16624 btn.addEventListener('click',function(){
16625 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
16626 btn.classList.add('active');
16627 renderComposition(btn.getAttribute('data-rcomp'));
16628 });
16629 });
16630
16631 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
16632 (function(){
16633 var el=document.getElementById('r-scatter-chart');
16634 if(!el||!SCAT_D||!SCAT_D.length)return;
16635 var H=224,PL=52,PB=36,PT=12,PR=14;
16636 var W=Math.max(320,el.offsetWidth||480);
16637 var cW=W-PL-PR,cH=H-PT-PB;
16638 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16639 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16640 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16641 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">';
16642 [0,0.25,0.5,0.75,1].forEach(function(t){
16643 var y=PT+cH*(1-t);
16644 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16645 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>';
16646 });
16647 [0,0.25,0.5,0.75,1].forEach(function(t){
16648 var x=PL+cW*t;
16649 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16650 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>';
16651 });
16652 SCAT_D.forEach(function(d,i){
16653 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
16654 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
16655 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"/>';
16656 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>';
16657 });
16658 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>';
16659 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>';
16660 s+='</svg>';
16661 el.innerHTML=s;
16662 })();
16663
16664 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
16665 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
16666 // the old vertical column layout on wide containers.
16667 function renderSemanticInEl(el,key,sh){
16668 if(!el||!SEM_D||!SEM_D.length)return;
16669 var LW=112,SH=sh||224;
16670 var svgW=Math.max(320,el.offsetWidth||480);
16671 var BW=Math.max(120,svgW-LW-80);
16672 var topPad=4,botPad=14;
16673 var n2=SEM_D.length||1;
16674 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
16675 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
16676 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
16677 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">';
16678 SEM_D.forEach(function(d,i){
16679 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
16680 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>';
16681 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"/>';
16682 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>';
16683 });
16684 s+='</svg>';
16685 el.innerHTML=s;
16686 }
16687 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,224);}
16688 var semSel=document.getElementById('r-semantic-metric');
16689 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
16690 var semExpand=document.getElementById('r-semantic-expand');
16691 if(semExpand){
16692 semExpand.addEventListener('click',function(){
16693 var key=semSel?semSel.value:'functions';
16694 var n=SEM_D.length||1;
16695 var modalH=Math.max(320,n*28+60);
16696 var overlay=document.createElement('div');
16697 overlay.className='r-chart-modal-overlay';
16698 overlay.innerHTML='<div class="r-chart-modal"><button class="r-chart-modal-close" aria-label="Close">×</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;"></div></div>';
16699 document.body.appendChild(overlay);
16700 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16701 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16702 var modalEl=document.getElementById('r-sem-modal-chart');
16703 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
16704 });
16705 }
16706
16707 // ── Comment Density: comments / (code + comments) per language ───────────
16708 function renderDensity(){
16709 var el=document.getElementById('r-density-chart');
16710 if(!el||!LANG_D||!LANG_D.length)return;
16711 var LW=112,SH=224;
16712 var svgW=Math.max(320,el.offsetWidth||480);
16713 var BW=Math.max(120,svgW-LW-80);
16714 var topPad=4,botPad=26;
16715 var n=LANG_D.length||1;
16716 var rowTotal=Math.floor((SH-topPad-botPad)/n);
16717 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16718 var densities=LANG_D.map(function(d){
16719 var sig=(d.code||0)+(d.comments||0);
16720 return sig>0?(d.comments||0)/sig:0;
16721 });
16722 var maxDen=Math.max.apply(null,densities)||1;
16723 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">';
16724 LANG_D.forEach(function(d,i){
16725 var den=densities[i],bw=den/maxDen*BW;
16726 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16727 var pct=Math.round(den*100);
16728 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>';
16729 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"/>';
16730 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16731 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>';
16732 });
16733 s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.5">comment ratio (higher = more documented)</text>';
16734 s+='</svg>';
16735 el.innerHTML=s;
16736 }
16737 renderDensity();
16738
16739 // ── Submodule: horizontal bar chart ────────────────────────────────────
16740 function renderSubmodule(key,sort){
16741 var el=document.getElementById('r-submodule-chart');
16742 if(!el||!SUB_D||!SUB_D.length)return;
16743 var data=SUB_D.slice();
16744 if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
16745 else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
16746 else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
16747 var LW=128,SH=224;
16748 var svgW=Math.max(320,el.offsetWidth||480);
16749 var BW=Math.max(120,svgW-LW-80);
16750 var topPad3=4,botPad3=14;
16751 var n3=data.length||1;
16752 var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
16753 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
16754 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
16755 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">';
16756 data.forEach(function(d,i){
16757 var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
16758 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.name||d.path||'?')+'</text>';
16759 if(bw>0.5)s+='<rect'+tt(d.name||'?',fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
16760 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>';
16761 });
16762 s+='</svg>';
16763 el.innerHTML=s;
16764 }
16765 var subSel=document.getElementById('r-sub-metric');
16766 var sortSel=document.getElementById('r-sub-sort');
16767 if(subSel){
16768 renderSubmodule('code','desc');
16769 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
16770 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
16771 }
16772
16773 // Re-render all SVG charts when the window is resized so bars fill the card.
16774 var _rResizeTimer;
16775 window.addEventListener('resize',function(){
16776 clearTimeout(_rResizeTimer);
16777 _rResizeTimer=setTimeout(function(){
16778 var rcompBtn=document.querySelector('[data-rcomp].active');
16779 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
16780 (function(){
16781 var scEl=document.getElementById('r-scatter-chart');
16782 if(!scEl||!SCAT_D||!SCAT_D.length)return;
16783 var H=224,PL=52,PB=36,PT=12,PR=14;
16784 var W=Math.max(320,scEl.offsetWidth||480);
16785 var cW=W-PL-PR,cH=H-PT-PB;
16786 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16787 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16788 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16789 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">';
16790 [0,0.25,0.5,0.75,1].forEach(function(t){var y=PT+cH*(1-t);s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';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>';});
16791 [0,0.25,0.5,0.75,1].forEach(function(t){var x=PL+cW*t;s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';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>';});
16792 SCAT_D.forEach(function(d,i){var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);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"/>';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>';});
16793 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>';
16794 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>';
16795 s+='</svg>';scEl.innerHTML=s;
16796 })();
16797 if(semSel)renderSemantic(semSel.value||'functions');
16798 renderDensity();
16799 if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
16800 },120);
16801 });
16802 })();
16803
16804 (function randomizeWatermarks() {
16805 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16806 if (!wms.length) return;
16807 var placed = [];
16808 function tooClose(top, left) {
16809 for (var i = 0; i < placed.length; i++) {
16810 var dt = Math.abs(placed[i][0] - top);
16811 var dl = Math.abs(placed[i][1] - left);
16812 if (dt < 20 && dl < 18) return true;
16813 }
16814 return false;
16815 }
16816 function pick(leftBand) {
16817 for (var attempt = 0; attempt < 50; attempt++) {
16818 var top = Math.random() * 85 + 5;
16819 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16820 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16821 }
16822 var top = Math.random() * 85 + 5;
16823 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16824 placed.push([top, left]);
16825 return [top, left];
16826 }
16827 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
16828 var half = Math.floor(wms.length / 2);
16829 wms.forEach(function (img, i) {
16830 var pos = pick(i < half);
16831 var size = Math.floor(Math.random() * 100 + 160);
16832 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
16833 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
16834 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;
16835 });
16836 })();
16837
16838 (function spawnCodeParticles() {
16839 var container = document.getElementById('code-particles');
16840 if (!container) return;
16841 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'];
16842 for (var i = 0; i < 38; i++) {
16843 (function(idx) {
16844 var el = document.createElement('span');
16845 el.className = 'code-particle';
16846 el.textContent = snippets[idx % snippets.length];
16847 var left = Math.random() * 94 + 2;
16848 var top = Math.random() * 88 + 6;
16849 var dur = (Math.random() * 10 + 9).toFixed(1);
16850 var delay = (Math.random() * 18).toFixed(1);
16851 var rot = (Math.random() * 26 - 13).toFixed(1);
16852 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16853 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';
16854 container.appendChild(el);
16855 })(i);
16856 }
16857 })();
16858
16859 {% if pdf_generating %}
16860 // Poll for PDF readiness and swap the disabled button to a live link once done.
16861 (function() {
16862 var openBtn = document.getElementById('pdf-open-btn');
16863 var dlBtn = document.getElementById('pdf-download-btn');
16864 function checkPdf() {
16865 fetch('/api/runs/{{ run_id }}/pdf-status')
16866 .then(function(r) { return r.json(); })
16867 .then(function(d) {
16868 if (d.ready) {
16869 if (openBtn) {
16870 var a = document.createElement('a');
16871 a.className = 'button';
16872 a.id = 'pdf-open-btn';
16873 a.href = '/runs/pdf/{{ run_id }}';
16874 a.target = '_blank';
16875 a.rel = 'noopener';
16876 a.textContent = 'Open PDF';
16877 openBtn.replaceWith(a);
16878 }
16879 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
16880 } else {
16881 setTimeout(checkPdf, 3000);
16882 }
16883 })
16884 .catch(function() { setTimeout(checkPdf, 5000); });
16885 }
16886 setTimeout(checkPdf, 3000);
16887 })();
16888 {% endif %}
16889
16890 })();
16891 </script>
16892 <script nonce="{{ csp_nonce }}">
16893 (function(){
16894 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'}];
16895 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);});}
16896 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16897 function init(){
16898 var btn=document.getElementById('settings-btn');if(!btn)return;
16899 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16900 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>';
16901 document.body.appendChild(m);
16902 var g=document.getElementById('scheme-grid');
16903 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);});
16904 var cl=document.getElementById('settings-close');
16905 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);
16906 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');});
16907 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16908 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16909 }
16910 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16911 }());
16912 </script>
16913 <footer class="site-footer">
16914 local code analysis - metrics, history and reports
16915 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
16916 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16917 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16918 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16919 · <a href="/api-docs" rel="noopener">REST API</a>
16920 </footer>
16921 {% if confluence_configured %}
16922 <script nonce="{{ csp_nonce }}">
16923 (function() {
16924 var postBtn = document.getElementById('postConfluenceBtn');
16925 var copyBtn = document.getElementById('copyWikiBtn');
16926 var modal = document.getElementById('confluenceModal');
16927 if (!postBtn || !modal) return;
16928
16929 postBtn.addEventListener('click', function() {
16930 document.getElementById('confStatus').style.display = 'none';
16931 modal.style.display = 'flex';
16932 });
16933 document.getElementById('confCancelBtn').addEventListener('click', function() {
16934 modal.style.display = 'none';
16935 });
16936 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
16937
16938 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
16939 var btn = this;
16940 btn.disabled = true;
16941 var status = document.getElementById('confStatus');
16942 status.style.display = 'block';
16943 status.style.background = '#dbeafe';
16944 status.style.color = '#1e40af';
16945 status.textContent = 'Posting to Confluence…';
16946 var resp = await fetch('/api/confluence/post', {
16947 method: 'POST',
16948 headers: { 'Content-Type': 'application/json' },
16949 body: JSON.stringify({
16950 run_id: '{{ run_id }}',
16951 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
16952 report_url: document.getElementById('confReportUrl').value.trim() || null
16953 })
16954 });
16955 var data = await resp.json();
16956 if (data.ok) {
16957 status.style.background = '#dcfce7'; status.style.color = '#166534';
16958 status.textContent = 'Posted! Page ID: ' + data.page_id;
16959 } else {
16960 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
16961 status.textContent = 'Error: ' + (data.error || 'Unknown error');
16962 }
16963 btn.disabled = false;
16964 });
16965
16966 if (copyBtn) {
16967 copyBtn.addEventListener('click', async function() {
16968 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
16969 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
16970 var text = await resp.text();
16971 try {
16972 await navigator.clipboard.writeText(text);
16973 var orig = copyBtn.textContent;
16974 copyBtn.textContent = 'Copied!';
16975 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
16976 } catch(e) {
16977 alert('Clipboard write failed — check browser permissions.');
16978 }
16979 });
16980 }
16981 })();
16982 </script>
16983 {% endif %}
16984 <script nonce="{{ csp_nonce }}">
16985 (function() {
16986 var deleteBtn = document.getElementById('delete-run-btn');
16987 var modal = document.getElementById('delete-run-modal');
16988 var cancelBtn = document.getElementById('delete-run-cancel');
16989 var confirmBtn= document.getElementById('delete-run-confirm');
16990 if (!deleteBtn || !modal) return;
16991 deleteBtn.addEventListener('click', function() {
16992 document.getElementById('delete-run-status').style.display = 'none';
16993 modal.style.display = 'flex';
16994 });
16995 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
16996 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
16997 confirmBtn.addEventListener('click', async function() {
16998 confirmBtn.disabled = true;
16999 cancelBtn.disabled = true;
17000 var status = document.getElementById('delete-run-status');
17001 status.style.display = 'block';
17002 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17003 status.textContent = 'Deleting…';
17004 try {
17005 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17006 if (resp.status === 204 || resp.ok) {
17007 status.style.background = '#dcfce7'; status.style.color = '#166534';
17008 status.textContent = 'Deleted. Redirecting…';
17009 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17010 } else {
17011 var d = await resp.json().catch(function(){return {};});
17012 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17013 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17014 confirmBtn.disabled = false;
17015 cancelBtn.disabled = false;
17016 }
17017 } catch (e) {
17018 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17019 status.textContent = 'Network error: ' + String(e);
17020 confirmBtn.disabled = false;
17021 cancelBtn.disabled = false;
17022 }
17023 });
17024 })();
17025 </script>
17026 <script nonce="{{ csp_nonce }}">(function(){
17027 var bundleBtn = document.getElementById('download-bundle-btn');
17028 if (bundleBtn) {
17029 bundleBtn.addEventListener('click', function() {
17030 bundleBtn.disabled = true;
17031 var orig = bundleBtn.textContent;
17032 bundleBtn.textContent = 'Preparing…';
17033 fetch('/api/runs/{{ run_id }}/bundle')
17034 .then(function(r) {
17035 if (!r.ok) throw new Error('HTTP ' + r.status);
17036 return r.blob();
17037 })
17038 .then(function(blob) {
17039 var url = URL.createObjectURL(blob);
17040 var a = document.createElement('a');
17041 a.href = url;
17042 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17043 document.body.appendChild(a);
17044 a.click();
17045 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17046 bundleBtn.disabled = false;
17047 bundleBtn.textContent = orig;
17048 })
17049 .catch(function(e) {
17050 bundleBtn.disabled = false;
17051 bundleBtn.textContent = orig;
17052 alert('Bundle download failed: ' + String(e));
17053 });
17054 });
17055 }
17056 })();</script>
17057 <script nonce="{{ csp_nonce }}">(function(){
17058 var dot=document.getElementById('status-dot');
17059 var pingEl=document.getElementById('server-ping-ms');
17060 var tipEl=document.getElementById('server-tip-ping');
17061 var fm=document.getElementById('footer-mode');
17062 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)';}}
17063 function doPing(){
17064 var t0=performance.now();
17065 fetch('/healthz',{cache:'no-store'})
17066 .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);})
17067 .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)';}});
17068 }
17069 doPing();
17070 setInterval(doPing,5000);
17071 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');}
17072 })();</script>
17073 {% if let Some(banner) = report_header_footer %}
17074 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17075 {% endif %}
17076</body>
17077</html>
17078"##,
17079 ext = "html"
17080)]
17081#[allow(clippy::struct_excessive_bools)]
17083struct ResultTemplate {
17084 version: &'static str,
17085 report_title: String,
17086 project_path: String,
17087 output_dir: String,
17088 run_id: String,
17089 files_analyzed: u64,
17090 files_skipped: u64,
17091 physical_lines: u64,
17092 code_lines: u64,
17093 comment_lines: u64,
17094 blank_lines: u64,
17095 mixed_lines: u64,
17096 functions: u64,
17097 classes: u64,
17098 variables: u64,
17099 imports: u64,
17100 html_url: Option<String>,
17101 pdf_url: Option<String>,
17102 json_url: Option<String>,
17103 html_download_url: Option<String>,
17104 pdf_download_url: Option<String>,
17105 json_download_url: Option<String>,
17106 html_path: Option<String>,
17107 json_path: Option<String>,
17108 prev_run_id: Option<String>,
17109 prev_run_timestamp: Option<String>,
17110 prev_run_code_lines: Option<u64>,
17111 prev_fa_str: String,
17113 prev_fs_str: String,
17114 prev_pl_str: String,
17115 prev_cl_str: String,
17116 prev_cml_str: String,
17117 prev_bl_str: String,
17118 delta_fa_str: String,
17120 delta_fa_class: String,
17121 delta_fs_str: String,
17122 delta_fs_class: String,
17123 delta_pl_str: String,
17124 delta_pl_class: String,
17125 delta_cl_str: String,
17126 delta_cl_class: String,
17127 delta_cml_str: String,
17128 delta_cml_class: String,
17129 delta_bl_str: String,
17130 delta_bl_class: String,
17131 delta_lines_added: Option<i64>,
17133 delta_lines_removed: Option<i64>,
17134 delta_lines_net_str: String,
17135 delta_lines_net_class: String,
17136 delta_files_added: Option<usize>,
17137 delta_files_removed: Option<usize>,
17138 delta_files_modified: Option<usize>,
17139 delta_files_unchanged: Option<usize>,
17140 delta_unmodified_lines: Option<u64>,
17141 git_branch: Option<String>,
17143 git_commit: Option<String>,
17144 git_commit_long: Option<String>,
17145 git_author: Option<String>,
17146 scan_performed_by: String,
17148 scan_time_display: String,
17149 os_display: String,
17150 test_count: u64,
17151 prev_scan_count: usize,
17153 current_scan_number: usize,
17154 submodule_rows: Vec<SubmoduleRow>,
17156 scan_config_url: String,
17157 lang_chart_json: String,
17158 #[allow(dead_code)]
17160 scatter_chart_json: String,
17161 #[allow(dead_code)]
17162 semantic_chart_json: String,
17163 #[allow(dead_code)]
17164 submodule_chart_json: String,
17165 #[allow(dead_code)]
17166 has_submodule_data: bool,
17167 #[allow(dead_code)]
17168 has_semantic_data: bool,
17169 pdf_generating: bool,
17170 csp_nonce: String,
17171 confluence_configured: bool,
17173 server_mode: bool,
17174 report_header_footer: Option<String>,
17176 run_id_short: String,
17177}
17178
17179#[derive(Template)]
17180#[template(
17181 source = r##"
17182<!doctype html>
17183<html lang="en">
17184<head>
17185 <meta charset="utf-8">
17186 <meta name="viewport" content="width=device-width, initial-scale=1">
17187 <title>OxideSLOC | Analyzing…</title>
17188 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17189 <style nonce="{{ csp_nonce }}">
17190 :root {
17191 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17192 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17193 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17194 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17195 }
17196 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17197 *{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;}
17198 .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);}
17199 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17200 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17201 .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));}
17202 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17203 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17204 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17205 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17206 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17207 @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; } }
17208 .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;}
17209 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17210 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17211 .page-body{padding:32px 24px 36px;}
17212 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17213 .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;}
17214 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17215 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17216 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17217 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17218 .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;}
17219 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17220 .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;}
17221 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17222 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17223 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17224 .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;}
17225 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17226 .hidden{display:none!important;}
17227 .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;}
17228 .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;}
17229 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17230 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17231 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17232 .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);}
17233 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17234 .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;}
17235 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17236 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17237 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17238 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17239 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17240 .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;}
17241 @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));}}
17242 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17243 .site-footer a{color:var(--muted);}
17244 .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;}
17245 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17246 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17247 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17248 </style>
17249</head>
17250<body>
17251 <div class="background-watermarks" aria-hidden="true">
17252 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17253 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17254 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17255 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17256 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17257 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17258 </div>
17259 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17260 <nav class="top-nav">
17261 <div class="top-nav-inner">
17262 <a href="/" class="brand">
17263 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17264 <div class="brand-copy">
17265 <h1 class="brand-title">OxideSLOC</h1>
17266 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17267 </div>
17268 </a>
17269 <div class="nav-right">
17270 <a class="nav-pill" href="/">Home</a>
17271 <div class="nav-dropdown">
17272 <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>
17273 <div class="nav-dropdown-menu">
17274 <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>
17275 </div>
17276 </div>
17277 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17278 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17279 <div class="nav-dropdown">
17280 <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>
17281 <div class="nav-dropdown-menu">
17282 <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>
17283 </div>
17284 </div>
17285 <div class="server-status-wrap" id="server-status-wrap">
17286 <div class="nav-pill server-online-pill" id="server-status-pill">
17287 <span class="status-dot" id="status-dot"></span>
17288 <span id="server-status-label">Server</span>
17289 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17290 </div>
17291 <div class="server-status-tip">
17292 OxideSLOC is running — accessible on your network.
17293 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17294 </div>
17295 </div>
17296 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17297 <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>
17298 </button>
17299 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17300 <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>
17301 <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>
17302 </button>
17303 </div>
17304 </div>
17305 </nav>
17306 <div class="page-body">
17307 <div class="wait-panel">
17308 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17309 <h2 class="wait-title">Analyzing your project…</h2>
17310 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
17311 <div class="path-block">{{ project_path }}</div>
17312 <div class="metrics-row">
17313 <div class="metric-card">
17314 <div class="metric-label">Elapsed</div>
17315 <div class="metric-value" id="elapsed">0s</div>
17316 </div>
17317 <div class="metric-card">
17318 <div class="metric-label">Phase</div>
17319 <div class="metric-value" id="phase">Starting</div>
17320 </div>
17321 </div>
17322 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17323 <div class="warn-slow hidden" id="warn-slow">
17324 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.
17325 </div>
17326 <div class="err-panel hidden" id="err-panel">
17327 <strong>Analysis failed</strong>
17328 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17329 </div>
17330 <div class="actions hidden" id="actions">
17331 <a href="/scan" class="btn-primary">Try Again</a>
17332 <a href="/view-reports" class="btn-outline">View Reports</a>
17333 </div>
17334 </div>
17335 </div>
17336 <script nonce="{{ csp_nonce }}">
17337 (function() {
17338 var WAIT_ID = {{ wait_id_json|safe }};
17339 var startTime = Date.now();
17340 var pollInterval = 1500;
17341 var retries = 0;
17342 var maxRetries = 5;
17343 var warnShown = false;
17344
17345 function elapsed() {
17346 return Math.floor((Date.now() - startTime) / 1000);
17347 }
17348
17349 function updateElapsed() {
17350 var s = elapsed();
17351 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17352 }
17353
17354 function setPhase(txt) {
17355 document.getElementById('phase').textContent = txt;
17356 }
17357
17358 var elapsedTimer = setInterval(updateElapsed, 1000);
17359
17360 function poll() {
17361 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17362 .then(function(r) {
17363 if (!r.ok) throw new Error('HTTP ' + r.status);
17364 return r.json();
17365 })
17366 .then(function(data) {
17367 retries = 0;
17368 if (data.state === 'complete') {
17369 clearInterval(elapsedTimer);
17370 setPhase('Done');
17371 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17372 } else if (data.state === 'failed') {
17373 clearInterval(elapsedTimer);
17374 setPhase('Failed');
17375 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17376 document.getElementById('err-panel').classList.remove('hidden');
17377 document.getElementById('actions').classList.remove('hidden');
17378 } else {
17379 // still running
17380 var s = elapsed();
17381 if (s > 90 && !warnShown) {
17382 warnShown = true;
17383 document.getElementById('warn-slow').classList.remove('hidden');
17384 }
17385 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
17386 setTimeout(poll, pollInterval);
17387 }
17388 })
17389 .catch(function(err) {
17390 retries++;
17391 if (retries >= maxRetries) {
17392 clearInterval(elapsedTimer);
17393 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17394 document.getElementById('err-panel').classList.remove('hidden');
17395 document.getElementById('actions').classList.remove('hidden');
17396 } else {
17397 // exponential back-off capped at 8s
17398 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17399 }
17400 });
17401 }
17402
17403 setTimeout(poll, pollInterval);
17404 })();
17405 </script>
17406 <footer class="site-footer">
17407 local code analysis - metrics, history and reports
17408 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17409 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17410 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17411 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17412 · <a href="/api-docs" rel="noopener">REST API</a>
17413 </footer>
17414 <script nonce="{{ csp_nonce }}">
17415 (function(){
17416 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17417 if(s==="dark")b.classList.add("dark-theme");
17418 var tt=document.getElementById("theme-toggle");
17419 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17420 })();
17421 (function spawnCodeParticles(){
17422 var c=document.getElementById('code-particles');if(!c)return;
17423 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'];
17424 for(var i=0;i<32;i++){(function(idx){
17425 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17426 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17427 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17428 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17429 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17430 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17431 c.appendChild(el);
17432 })(i);}
17433 })();
17434 (function randomizeWatermarks(){
17435 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17436 var placed=[];
17437 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;}
17438 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];}
17439 var half=Math.floor(wms.length/2);
17440 wms.forEach(function(img,i){
17441 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17442 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17443 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17444 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17445 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17446 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17447 });
17448 })();
17449 </script>
17450 <script nonce="{{ csp_nonce }}">
17451 (function(){
17452 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'}];
17453 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);});}
17454 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17455 function init(){
17456 var btn=document.getElementById('settings-btn');if(!btn)return;
17457 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17458 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>';
17459 document.body.appendChild(m);
17460 var g=document.getElementById('scheme-grid');
17461 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);});
17462 var cl=document.getElementById('settings-close');
17463 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);
17464 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');});
17465 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17466 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17467 }
17468 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17469 }());
17470 </script>
17471 <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>
17472</body>
17473</html>
17474"##,
17475 ext = "html"
17476)]
17477struct ScanWaitTemplate {
17478 version: &'static str,
17479 wait_id_json: String,
17480 project_path: String,
17481 csp_nonce: String,
17482}
17483
17484#[derive(Template)]
17485#[template(
17486 source = r##"
17487<!doctype html>
17488<html lang="en">
17489<head>
17490 <meta charset="utf-8">
17491 <meta name="viewport" content="width=device-width, initial-scale=1">
17492 <title>OxideSLOC | Error</title>
17493 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17494 <style nonce="{{ csp_nonce }}">
17495 :root {
17496 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17497 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17498 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17499 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17500 }
17501 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17502 *{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;}
17503 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17504 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17505 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17506 .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);}
17507 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17508 .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));}
17509 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17510 .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;}
17511 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17512 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17513 @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; } }
17514 .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;}
17515 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17516 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17517 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17518 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17519 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17520 .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;}
17521 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17522 .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);}
17523 .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;}
17524 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17525 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17526 .settings-modal-body{padding:14px 16px 16px;}
17527 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17528 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17529 .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;}
17530 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17531 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17532 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17533 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17534 .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;}
17535 .tz-select:focus{border-color:var(--oxide);}
17536 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17537 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17538 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17539 .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;}
17540 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17541 .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);}
17542 .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;}
17543 .btn-secondary:hover{background:var(--line);}
17544 .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;}
17545 .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;}
17546 .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;}
17547 @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));}}
17548 .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;}
17549 </style>
17550</head>
17551<body>
17552 <div class="background-watermarks" aria-hidden="true">
17553 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17554 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17555 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17556 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17557 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17558 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17559 </div>
17560 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17561 <div class="top-nav">
17562 <div class="top-nav-inner">
17563 <a class="brand" href="/">
17564 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17565 <div class="brand-copy">
17566 <div class="brand-title">OxideSLOC</div>
17567 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17568 </div>
17569 </a>
17570 <div class="nav-right">
17571 <a class="nav-pill" href="/">Home</a>
17572 <div class="nav-dropdown">
17573 <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>
17574 <div class="nav-dropdown-menu">
17575 <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>
17576 </div>
17577 </div>
17578 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17579 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17580 <div class="nav-dropdown">
17581 <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>
17582 <div class="nav-dropdown-menu">
17583 <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>
17584 </div>
17585 </div>
17586 <div class="server-status-wrap" id="server-status-wrap">
17587 <div class="nav-pill server-online-pill" id="server-status-pill">
17588 <span class="status-dot" id="status-dot"></span>
17589 <span id="server-status-label">Server</span>
17590 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17591 </div>
17592 <div class="server-status-tip">
17593 OxideSLOC is running — accessible on your network.
17594 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17595 </div>
17596 </div>
17597 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17598 <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>
17599 </button>
17600 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17601 <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>
17602 <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>
17603 </button>
17604 </div>
17605 </div>
17606 </div>
17607
17608 <div class="page">
17609 <div class="panel">
17610 <h1>Error</h1>
17611 <div class="error-box">{{ message }}</div>
17612 <div class="actions">
17613 <a class="btn-primary" href="/scan">Back to setup</a>
17614 {% if let Some(report_url) = last_report_url %}
17615 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
17616 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
17617 {% else %}
17618 <a class="btn-secondary" href="/view-reports">View Reports</a>
17619 {% endif %}
17620 </div>
17621 </div>
17622 </div>
17623 <script nonce="{{ csp_nonce }}">
17624 (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");});})();
17625 (function spawnCodeParticles() {
17626 var container = document.getElementById('code-particles');
17627 if (!container) return;
17628 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'];
17629 for (var i = 0; i < 38; i++) {
17630 (function(idx) {
17631 var el = document.createElement('span');
17632 el.className = 'code-particle';
17633 el.textContent = snippets[idx % snippets.length];
17634 var left = Math.random() * 94 + 2;
17635 var top = Math.random() * 88 + 6;
17636 var dur = (Math.random() * 10 + 9).toFixed(1);
17637 var delay = (Math.random() * 18).toFixed(1);
17638 var rot = (Math.random() * 26 - 13).toFixed(1);
17639 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17640 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';
17641 container.appendChild(el);
17642 })(i);
17643 }
17644 })();
17645 (function randomizeWatermarks() {
17646 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17647 var placed = [];
17648 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; }
17649 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]; }
17650 var half = Math.floor(wms.length/2);
17651 wms.forEach(function(img, i) {
17652 var pos = pick(i < half);
17653 var w = Math.floor(Math.random()*60+80);
17654 var rot = (Math.random()*40-20).toFixed(1);
17655 var op = (Math.random()*0.08+0.05).toFixed(2);
17656 var animDur = (Math.random()*6+5).toFixed(1);
17657 var animDelay = (Math.random()*10).toFixed(1);
17658 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';
17659 });
17660 })();
17661 </script>
17662 <script nonce="{{ csp_nonce }}">
17663 (function(){
17664 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'}];
17665 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);});}
17666 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17667 function init(){
17668 var btn=document.getElementById('settings-btn');if(!btn)return;
17669 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17670 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>';
17671 document.body.appendChild(m);
17672 var g=document.getElementById('scheme-grid');
17673 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);});
17674 var cl=document.getElementById('settings-close');
17675 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);
17676 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');});
17677 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17678 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17679 }
17680 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17681 }());
17682 </script>
17683 <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>
17684</body>
17685</html>
17686"##,
17687 ext = "html"
17688)]
17689struct ErrorTemplate {
17690 message: String,
17691 last_report_url: Option<String>,
17693 last_report_label: Option<String>,
17695 csp_nonce: String,
17696 version: &'static str,
17697}
17698
17699#[derive(Template)]
17702#[template(
17703 source = r##"
17704<!doctype html>
17705<html lang="en">
17706<head>
17707 <meta charset="utf-8">
17708 <meta name="viewport" content="width=device-width, initial-scale=1">
17709 <title>OxideSLOC | Locate Scan Files</title>
17710 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17711 <style nonce="{{ csp_nonce }}">
17712 :root {
17713 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17714 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17715 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17716 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17717 }
17718 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17719 *{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;}
17720 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17721 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17722 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17723 .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);}
17724 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17725 .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));}
17726 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17727 .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;}
17728 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17729 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
17730 @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;}}
17731 .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;}
17732 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17733 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17734 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17735 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17736 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17737 .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;}
17738 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17739 .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);}
17740 .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;}
17741 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17742 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17743 .settings-modal-body{padding:14px 16px 16px;}
17744 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17745 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17746 .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;}
17747 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17748 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17749 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17750 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17751 .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;}
17752 .tz-select:focus{border-color:var(--oxide);}
17753 .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17754 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17755 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17756 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
17757 .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;}
17758 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17759 .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;}
17760 .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;}
17761 .btn-secondary:hover{background:var(--line);}
17762 .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;}
17763 .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;}
17764 .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;}
17765 @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));}}
17766 .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;}
17767 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
17768 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
17769 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
17770 .relocate-row{display:flex;gap:8px;align-items:stretch;}
17771 .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;}
17772 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
17773 body.dark-theme .relocate-input{background:var(--surface-2);}
17774 </style>
17775</head>
17776<body>
17777 <div class="background-watermarks" aria-hidden="true">
17778 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17779 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17780 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17781 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17782 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17783 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17784 </div>
17785 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17786 <div class="top-nav">
17787 <div class="top-nav-inner">
17788 <a class="brand" href="/">
17789 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17790 <div class="brand-copy">
17791 <div class="brand-title">OxideSLOC</div>
17792 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17793 </div>
17794 </a>
17795 <div class="nav-right">
17796 <a class="nav-pill" href="/">Home</a>
17797 <div class="nav-dropdown">
17798 <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>
17799 <div class="nav-dropdown-menu">
17800 <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>
17801 </div>
17802 </div>
17803 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
17804 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17805 <div class="nav-dropdown">
17806 <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>
17807 <div class="nav-dropdown-menu">
17808 <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>
17809 </div>
17810 </div>
17811 <div class="server-status-wrap" id="server-status-wrap">
17812 <div class="nav-pill server-online-pill" id="server-status-pill">
17813 <span class="status-dot" id="status-dot"></span>
17814 <span id="server-status-label">Server</span>
17815 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17816 </div>
17817 <div class="server-status-tip">
17818 OxideSLOC is running — accessible on your network.
17819 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17820 </div>
17821 </div>
17822 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17823 <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>
17824 </button>
17825 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17826 <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>
17827 <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>
17828 </button>
17829 </div>
17830 </div>
17831 </div>
17832
17833 <div class="page">
17834 <div class="panel">
17835 <h1>Scan Files Moved</h1>
17836 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
17837 <div class="error-box">{{ message }}</div>
17838 <div class="relocate-section">
17839 <h2>Locate Scan Output</h2>
17840 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
17841 <form method="post" action="/relocate-scan">
17842 <input type="hidden" name="run_id" value="{{ run_id }}">
17843 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
17844 <div class="relocate-row">
17845 <input type="text" id="relocate-folder" name="folder_path"
17846 value="{{ folder_hint }}"
17847 placeholder="Path to folder containing scan output..."
17848 class="relocate-input" autocomplete="off" spellcheck="false">
17849 {% if !server_mode %}
17850 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
17851 {% endif %}
17852 </div>
17853 <div style="margin-top:12px;">
17854 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
17855 </div>
17856 </form>
17857 </div>
17858 <div class="actions">
17859 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
17860 <a class="btn-secondary" href="/view-reports">View Reports</a>
17861 </div>
17862 </div>
17863 </div>
17864 <script nonce="{{ csp_nonce }}">
17865 (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");});})();
17866 (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);}})();
17867 (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';});})();
17868 </script>
17869 <script nonce="{{ csp_nonce }}">
17870 (function(){
17871 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'}];
17872 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);});}
17873 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17874 function init(){
17875 var btn=document.getElementById('settings-btn');if(!btn)return;
17876 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17877 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>';
17878 document.body.appendChild(m);
17879 var g=document.getElementById('scheme-grid');
17880 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);});
17881 var cl=document.getElementById('settings-close');
17882 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);
17883 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');});
17884 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17885 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17886 }
17887 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17888 }());
17889 (function(){
17890 var btn=document.getElementById('browse-relocate-btn');
17891 if(!btn)return;
17892 btn.addEventListener('click',function(){
17893 btn.disabled=true;btn.textContent='...';
17894 var inp=document.getElementById('relocate-folder');
17895 var hint=inp?inp.value:'';
17896 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
17897 .then(function(r){return r.ok?r.json():{cancelled:true};})
17898 .then(function(d){
17899 btn.disabled=false;btn.textContent='Browse…';
17900 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
17901 })
17902 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
17903 });
17904 }());
17905 </script>
17906 <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>
17907</body>
17908</html>
17909"##,
17910 ext = "html"
17911)]
17912struct RelocateScanTemplate {
17913 message: String,
17914 run_id: String,
17915 folder_hint: String,
17916 redirect_url: String,
17917 server_mode: bool,
17918 csp_nonce: String,
17919 version: &'static str,
17920}
17921
17922#[derive(Template)]
17925#[template(
17926 source = r##"
17927<!doctype html>
17928<html lang="en">
17929<head>
17930 <meta charset="utf-8">
17931 <meta name="viewport" content="width=device-width, initial-scale=1">
17932 <title>OxideSLOC | View Reports</title>
17933 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17934 <style nonce="{{ csp_nonce }}">
17935 :root {
17936 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
17937 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17938 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
17939 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17940 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
17941 }
17942 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; }
17943 *{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;}
17944 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17945 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17946 .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);}
17947 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17948 .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));}
17949 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17950 .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;}
17951 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17952 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17953 @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; } }
17954 .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;}
17955 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17956 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17957 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17958 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17959 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17960 .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;}
17961 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17962 .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);}
17963 .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;}
17964 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17965 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17966 .settings-modal-body{padding:14px 16px 16px;}
17967 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17968 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17969 .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;}
17970 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17971 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17972 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17973 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17974 .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;}
17975 .tz-select:focus{border-color:var(--oxide);}
17976 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
17977 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17978 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
17979 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
17980 .panel-meta{font-size:13px;color:var(--muted);}
17981 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
17982 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
17983 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
17984 .per-page-label{font-size:13px;color:var(--muted);}
17985 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;}
17986 .filter-input{min-width:180px;cursor:text;}
17987 .table-wrap{width:100%;overflow-x:auto;}
17988 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
17989 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;}
17990 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17991 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17992 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17993 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17994 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17995 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17996 tr:last-child td{border-bottom:none;}
17997 tr:hover td{background:var(--surface-2);}
17998 .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);}
17999 .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);}
18000 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18001 .metric-num{font-weight:700;color:var(--text);}
18002 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18003 .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;}
18004 .btn:hover{background:var(--line);}
18005 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18006 .btn.primary:hover{opacity:.9;}
18007 .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;}
18008 .btn-back:hover{background:var(--line);}
18009 .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;}
18010 .export-btn:hover{background:var(--line);}
18011 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18012 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18013 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18014 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18015 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18016 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18017 .pagination-info{font-size:13px;color:var(--muted);}
18018 .pagination-btns{display:flex;gap:6px;}
18019 .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;}
18020 .pg-btn:hover:not(:disabled){background:var(--line);}
18021 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18022 .pg-btn:disabled{opacity:.35;cursor:default;}
18023 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18024 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18025 .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;}
18026 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18027 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18028 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18029 .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);}
18030 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18031 .stat-chip:hover .stat-chip-tip{opacity:1;}
18032 .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;}
18033 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18034 .site-footer a{color:var(--muted);}
18035 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18036 .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%;}
18037 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18038 .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;}
18039 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18040 .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;}
18041 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18042 .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;}
18043 .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;}
18044 .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;}
18045 @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));}}
18046 .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;}
18047 .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;}
18048 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18049 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18050 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18051 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18052 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18053 .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;}
18054 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18055 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18056 .watched-chip-rm:hover{color:var(--oxide);}
18057 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18058 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18059 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18060 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18061 .rpt-btn{min-width:58px;justify-content:center;}
18062 .flex-row{display:flex;align-items:center;gap:8px;}
18063 .report-cell{overflow:visible;white-space:normal;}
18064 #history-table col:nth-child(1){width:185px;}
18065 #history-table col:nth-child(2){width:220px;}
18066 #history-table col:nth-child(3){width:100px;}
18067 #history-table col:nth-child(4){width:72px;}
18068 #history-table col:nth-child(5){width:82px;}
18069 #history-table col:nth-child(6){width:82px;}
18070 #history-table col:nth-child(7){width:65px;}
18071 #history-table col:nth-child(8){width:90px;}
18072 #history-table col:nth-child(9){width:85px;}
18073 #history-table col:nth-child(10){width:115px;}
18074 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18075 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18076 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18077 .submod-details summary::-webkit-details-marker{display:none;}
18078.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18079 .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;}
18080 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18081 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18082 </style>
18083</head>
18084<body>
18085 <div class="background-watermarks" aria-hidden="true">
18086 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18087 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18088 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18089 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18090 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18091 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18092 </div>
18093 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18094 <div class="top-nav">
18095 <div class="top-nav-inner">
18096 <a class="brand" href="/">
18097 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18098 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18099 </a>
18100 <div class="nav-right">
18101 <a class="nav-pill" href="/">Home</a>
18102 <div class="nav-dropdown">
18103 <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>
18104 <div class="nav-dropdown-menu">
18105 <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>
18106 </div>
18107 </div>
18108 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18109 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18110 <div class="nav-dropdown">
18111 <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>
18112 <div class="nav-dropdown-menu">
18113 <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>
18114 </div>
18115 </div>
18116 <div class="server-status-wrap" id="server-status-wrap">
18117 <div class="nav-pill server-online-pill" id="server-status-pill">
18118 <span class="status-dot" id="status-dot"></span>
18119 <span id="server-status-label">Server</span>
18120 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18121 </div>
18122 <div class="server-status-tip">
18123 OxideSLOC is running — accessible on your network.
18124 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18125 </div>
18126 </div>
18127 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18128 <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>
18129 </button>
18130 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18131 <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>
18132 <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>
18133 </button>
18134 </div>
18135 </div>
18136 </div>
18137
18138 <div class="page">
18139 {% if let Some(err) = browse_error %}
18140 <div class="toast-error">
18141 <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>
18142 {{ err }}
18143 </div>
18144 {% endif %}
18145 {% if linked_count > 0 %}
18146 <div class="toast-success">
18147 <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>
18148 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18149 </div>
18150 {% endif %}
18151 <div class="watched-bar">
18152 <div class="watched-bar-left">
18153 <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>
18154 <span class="watched-label">Watched Folders</span>
18155 <div class="watched-chips">
18156 {% if server_mode %}
18157 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18158 {% else %}
18159 {% for dir in watched_dirs %}
18160 <span class="watched-chip">
18161 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18162 <form method="POST" action="/watched-dirs/remove" style="display:contents">
18163 <input type="hidden" name="folder_path" value="{{ dir }}">
18164 <input type="hidden" name="redirect_to" value="/view-reports">
18165 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
18166 </form>
18167 </span>
18168 {% endfor %}
18169 {% if watched_dirs.is_empty() %}
18170 <span class="watched-none">No folders watched — click Choose to add one</span>
18171 {% endif %}
18172 {% endif %}
18173 </div>
18174 </div>
18175 {% if !server_mode %}
18176 <div class="watched-bar-right">
18177 <button type="button" class="btn" id="add-watched-btn">
18178 <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>
18179 Choose
18180 </button>
18181 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18182 <input type="hidden" name="redirect_to" value="/view-reports">
18183 <button type="submit" class="btn">↻ Refresh</button>
18184 </form>
18185 </div>
18186 {% endif %}
18187 </div>
18188 {% if total_scans > 0 %}
18189 <div class="summary-strip">
18190 <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>
18191 <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>
18192 <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>
18193 <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
18194 </div>
18195 {% endif %}
18196
18197 <section class="panel">
18198 <div class="panel-header">
18199 <div>
18200 <h1>View Reports</h1>
18201 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18202 {% 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 %}
18203 </div>
18204 <div class="flex-row">
18205 <button type="button" class="export-btn" id="export-csv-btn">
18206 <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>
18207 Export CSV
18208 </button>
18209 <button type="button" class="export-btn" id="export-xls-btn">
18210 <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>
18211 Export Excel
18212 </button>
18213 </div>
18214 </div>
18215
18216 {% if entries.is_empty() %}
18217 <div class="empty-state">
18218 <strong>No reports with viewable HTML yet</strong>
18219 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.
18220 </div>
18221 {% else %}
18222 <div class="filter-row">
18223 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
18224 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18225 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
18226 </div>
18227 <div class="table-wrap">
18228 <table id="history-table">
18229 <colgroup>
18230 <col><col><col><col><col><col><col><col><col><col>
18231 </colgroup>
18232 <thead>
18233 <tr id="history-thead">
18234 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18235 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18236 <th>Run ID<div class="col-resize-handle"></div></th>
18237 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18238 <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>
18239 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18240 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18241 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18242 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18243 <th>Report<div class="col-resize-handle"></div></th>
18244 </tr>
18245 </thead>
18246 <tbody id="history-tbody">
18247 {% for entry in entries %}
18248 <tr class="history-row" data-run="{{ entry.run_id }}"
18249 data-timestamp="{{ entry.timestamp }}"
18250 data-project="{{ entry.project_label }}"
18251 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18252 data-skipped="{{ entry.files_skipped }}"
18253 data-comments="{{ entry.comment_lines }}"
18254 data-blank="{{ entry.blank_lines }}"
18255 data-branch="{{ entry.git_branch }}"
18256 data-commit="{{ entry.git_commit }}"
18257 data-html-url="/runs/html/{{ entry.run_id }}">
18258 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18259 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18260 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18261 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18262 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18263 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18264 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18265 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
18266 <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>
18267 <td class="report-cell">
18268 <div class="actions-cell">
18269 {% 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 %}
18270 {% 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 %}
18271 </div>
18272 {% if !entry.submodule_links.is_empty() %}
18273 <details class="submod-details">
18274 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
18275 <div class="submod-link-list">
18276 {% for sub in entry.submodule_links %}
18277 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18278 {% endfor %}
18279 </div>
18280 </details>
18281 {% endif %}
18282 </td>
18283 </tr>
18284 {% endfor %}
18285 </tbody>
18286 </table>
18287 </div>
18288 <div class="pagination">
18289 <span class="pagination-info" id="pagination-info"></span>
18290 <div class="pagination-btns" id="pagination-btns"></div>
18291 <div class="flex-row">
18292 <span class="per-page-label">Show</span>
18293 <select class="per-page" id="per-page-sel">
18294 <option value="10">10 per page</option>
18295 <option value="25" selected>25 per page</option>
18296 <option value="50">50 per page</option>
18297 <option value="100">100 per page</option>
18298 </select>
18299 <span class="per-page-label" id="page-range-label"></span>
18300 </div>
18301 </div>
18302 {% endif %}
18303 </section>
18304 </div>
18305
18306 <footer class="site-footer">
18307 local code analysis - metrics, history and reports
18308 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
18309 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18310 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18311 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18312 · <a href="/api-docs" rel="noopener">REST API</a>
18313 </footer>
18314
18315 <script nonce="{{ csp_nonce }}">
18316 (function () {
18317 // ── Theme ──────────────────────────────────────────────────────────────
18318 var storageKey = 'oxide-sloc-theme';
18319 var body = document.body;
18320 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18321 var toggle = document.getElementById('theme-toggle');
18322 if (toggle) toggle.addEventListener('click', function () {
18323 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18324 body.classList.toggle('dark-theme', next === 'dark');
18325 try { localStorage.setItem(storageKey, next); } catch(e) {}
18326 });
18327
18328 // ── State ─────────────────────────────────────────────────────────────
18329 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18330 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18331 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18332
18333 // Aggregate stats from first (most recent) row
18334 if (allRows.length) {
18335 var first = allRows[0];
18336 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();}
18337 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>':'');}
18338 setChipVal('agg-code', first.dataset.code);
18339 setChipVal('agg-files', first.dataset.files);
18340 setChipVal('agg-skipped', first.dataset.skipped);
18341 }
18342
18343 // ── Branch filter population ──────────────────────────────────────────
18344 (function() {
18345 var branches = {};
18346 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18347 var sel = document.getElementById('branch-filter');
18348 if (sel) Object.keys(branches).sort().forEach(function(b) {
18349 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18350 });
18351 })();
18352
18353 // ── Filter ────────────────────────────────────────────────────────────
18354 function getFilteredRows() {
18355 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18356 var branch = ((document.getElementById('branch-filter') || {}).value || '');
18357 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18358 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18359 if (branch && (r.dataset.branch || '') !== branch) return false;
18360 return true;
18361 });
18362 }
18363
18364 // ── Pagination ────────────────────────────────────────────────────────
18365 function renderPage() {
18366 var filtered = getFilteredRows();
18367 var total = filtered.length;
18368 var totalPages = Math.max(1, Math.ceil(total / perPage));
18369 currentPage = Math.min(currentPage, totalPages);
18370 var start = (currentPage - 1) * perPage;
18371 var end = Math.min(start + perPage, total);
18372 var shown = {};
18373 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
18374 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
18375 r.style.display = shown[r.dataset.run] ? '' : 'none';
18376 });
18377 var rl = document.getElementById('page-range-label');
18378 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
18379 var info = document.getElementById('pagination-info');
18380 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
18381 var btns = document.getElementById('pagination-btns');
18382 if (!btns) return;
18383 btns.innerHTML = '';
18384 function makeBtn(lbl, pg, active, disabled) {
18385 var b = document.createElement('button');
18386 b.className = 'pg-btn' + (active ? ' active' : '');
18387 b.textContent = lbl; b.disabled = disabled;
18388 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
18389 return b;
18390 }
18391 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
18392 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
18393 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
18394 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
18395 }
18396
18397 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
18398 window.applyFilters = function() { currentPage = 1; renderPage(); };
18399
18400 // ── Sorting ───────────────────────────────────────────────────────────
18401 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
18402 function doSort(col, type, order) {
18403 var tbody = document.getElementById('history-tbody');
18404 if (!tbody) return;
18405 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18406 rows.sort(function(a, b) {
18407 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
18408 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
18409 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18410 return va < vb ? 1 : va > vb ? -1 : 0;
18411 });
18412 rows.forEach(function(r) { tbody.appendChild(r); });
18413 currentPage = 1; renderPage();
18414 }
18415 sortHeaders.forEach(function(th) {
18416 th.addEventListener('click', function(e) {
18417 if (e.target.classList.contains('col-resize-handle')) return;
18418 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
18419 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
18420 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18421 th.classList.add('sort-' + sortOrder);
18422 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
18423 doSort(col, type, sortOrder);
18424 });
18425 });
18426
18427 // ── Column resize ─────────────────────────────────────────────────────
18428 (function() {
18429 var table = document.getElementById('history-table');
18430 if (!table) return;
18431 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18432 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
18433 ths.forEach(function(th, i) {
18434 var handle = th.querySelector('.col-resize-handle');
18435 if (!handle || !cols[i]) return;
18436 var startX, startW;
18437 handle.addEventListener('mousedown', function(e) {
18438 e.stopPropagation(); e.preventDefault();
18439 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18440 handle.classList.add('dragging');
18441 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18442 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18443 document.addEventListener('mousemove', onMove);
18444 document.addEventListener('mouseup', onUp);
18445 });
18446 });
18447 })();
18448
18449 // ── Reset view ────────────────────────────────────────────────────────
18450 window.resetView = function() {
18451 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
18452 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
18453 sortCol = null; sortOrder = 'asc';
18454 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18455 var tbody = document.getElementById('history-tbody');
18456 if (tbody) {
18457 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18458 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18459 rows.forEach(function(r) { tbody.appendChild(r); });
18460 }
18461 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
18462 var table = document.getElementById('history-table');
18463 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18464 currentPage = 1; renderPage();
18465 };
18466
18467 renderPage();
18468
18469 // ── Export helpers ────────────────────────────────────────────────────
18470 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
18471 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18472 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);}
18473 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;');}
18474 function slocXlsx(fname,sheet,hdrs,rows){
18475 var enc=new TextEncoder();
18476 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;}
18477 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;}
18478 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18479 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18480 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18481 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;}
18482 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];}
18483 var rx='<row r="1">';
18484 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
18485 rx+='</row>';
18486 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>';});
18487 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18488 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>';
18489 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>';
18490 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>';
18491 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>',
18492 '_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>',
18493 '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>',
18494 '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>',
18495 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
18496 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'];
18497 var zparts=[],zcds=[],zoff=0,znf=0;
18498 order.forEach(function(name){
18499 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18500 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]);
18501 var entry=new Uint8Array(lha.length+nb.length+sz);
18502 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18503 zparts.push(entry);
18504 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));
18505 var cde=new Uint8Array(cda.length+nb.length);
18506 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18507 zcds.push(cde);zoff+=entry.length;znf++;
18508 });
18509 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18510 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]);
18511 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18512 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18513 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18514 zout.set(new Uint8Array(ea),zpos);
18515 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
18516 }
18517
18518 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
18519 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;}
18520 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
18521 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
18522
18523 var csvBtn = document.getElementById('export-csv-btn');
18524 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
18525 var xlsBtn = document.getElementById('export-xls-btn');
18526 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
18527
18528 // ── Remaining CSP-safe event bindings ────────────────────────────────
18529 (function wireEvents() {
18530 var el;
18531 el = document.getElementById('reset-view-btn');
18532 if (el) el.addEventListener('click', window.resetView);
18533 el = document.getElementById('project-filter');
18534 if (el) el.addEventListener('input', window.applyFilters);
18535 el = document.getElementById('branch-filter');
18536 if (el) el.addEventListener('change', window.applyFilters);
18537 el = document.getElementById('per-page-sel');
18538 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
18539 el = document.getElementById('add-watched-btn');
18540 if (el) el.addEventListener('click', function() {
18541 fetch('/pick-directory?kind=reports')
18542 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
18543 .then(function(data) {
18544 if (!data.cancelled && data.selected_path) {
18545 var form = document.createElement('form');
18546 form.method = 'POST';
18547 form.action = '/watched-dirs/add';
18548 var ri = document.createElement('input');
18549 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
18550 var fi = document.createElement('input');
18551 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
18552 form.appendChild(ri); form.appendChild(fi);
18553 document.body.appendChild(form);
18554 form.submit();
18555 }
18556 })
18557 .catch(function(e) { alert('Could not open folder picker: ' + e); });
18558 });
18559 })();
18560
18561 (function randomizeWatermarks() {
18562 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18563 if (!wms.length) return;
18564 var placed = [];
18565 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;}
18566 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];}
18567 var half=Math.floor(wms.length/2);
18568 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;});
18569 })();
18570
18571 (function spawnCodeParticles() {
18572 var container = document.getElementById('code-particles');
18573 if (!container) return;
18574 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'];
18575 for (var i = 0; i < 38; i++) {
18576 (function(idx) {
18577 var el = document.createElement('span');
18578 el.className = 'code-particle';
18579 el.textContent = snippets[idx % snippets.length];
18580 var left = Math.random() * 94 + 2;
18581 var top = Math.random() * 88 + 6;
18582 var dur = (Math.random() * 10 + 9).toFixed(1);
18583 var delay = (Math.random() * 18).toFixed(1);
18584 var rot = (Math.random() * 26 - 13).toFixed(1);
18585 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18586 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';
18587 container.appendChild(el);
18588 })(i);
18589 }
18590 })();
18591 })();
18592 </script>
18593 <script nonce="{{ csp_nonce }}">
18594 (function(){
18595 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'}];
18596 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);});}
18597 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18598 function init(){
18599 var btn=document.getElementById('settings-btn');if(!btn)return;
18600 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18601 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>';
18602 document.body.appendChild(m);
18603 var g=document.getElementById('scheme-grid');
18604 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);});
18605 var cl=document.getElementById('settings-close');
18606 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);
18607 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');});
18608 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18609 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18610 }
18611 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18612 }());
18613 </script>
18614 <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>
18615</body>
18616</html>
18617"##,
18618 ext = "html"
18619)]
18620struct HistoryTemplate {
18621 version: &'static str,
18622 entries: Vec<HistoryEntryRow>,
18623 total_scans: usize,
18624 linked_count: usize,
18625 browse_error: Option<String>,
18626 watched_dirs: Vec<String>,
18627 csp_nonce: String,
18628 server_mode: bool,
18629}
18630
18631#[derive(Template)]
18634#[template(
18635 source = r##"
18636<!doctype html>
18637<html lang="en">
18638<head>
18639 <meta charset="utf-8">
18640 <meta name="viewport" content="width=device-width, initial-scale=1">
18641 <title>OxideSLOC | Compare Scans</title>
18642 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18643 <style nonce="{{ csp_nonce }}">
18644 :root {
18645 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18646 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18647 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18648 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18649 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
18650 }
18651 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18652 *{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;}
18653 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18654 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18655 .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);}
18656 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18657 .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));}
18658 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18659 .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;}
18660 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18661 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18662 @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; } }
18663 .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;}
18664 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18665 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18666 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18667 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18668 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18669 .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;}
18670 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18671 .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);}
18672 .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;}
18673 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18674 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18675 .settings-modal-body{padding:14px 16px 16px;}
18676 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18677 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18678 .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;}
18679 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18680 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18681 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18682 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18683 .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;}
18684 .tz-select:focus{border-color:var(--oxide);}
18685 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18686 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18687 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18688 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18689 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
18690 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
18691 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18692 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18693 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18694 .per-page-label{font-size:13px;color:var(--muted);}
18695 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;}
18696 .filter-input{min-width:180px;cursor:text;}
18697 .table-wrap{width:100%;overflow-x:auto;}
18698 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
18699 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;}
18700 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18701 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18702 #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;}
18703 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
18704 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
18705 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
18706 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
18707 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
18708 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
18709 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
18710 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
18711 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
18712 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18713 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18714 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18715 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18716 tr:last-child td{border-bottom:none;}
18717 tr.selected td{background:var(--sel-bg);}
18718 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
18719 tr:hover:not(.selected) td{background:var(--surface-2);}
18720 tr{cursor:pointer;}
18721 .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);}
18722 .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);}
18723 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18724 .metric-num{font-weight:700;color:var(--text);}
18725 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18726 .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;}
18727 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
18728 .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;}
18729 .btn:hover{background:var(--line);}
18730 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
18731 .btn.primary:hover{opacity:.9;}
18732 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
18733 .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;}
18734 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18735 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18736 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18737 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18738 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18739 .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;}
18740 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18741 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18742 .watched-chip-rm:hover{color:var(--oxide);}
18743 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18744 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18745 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18746 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18747 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
18748 .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;}
18749 .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;}
18750 .btn-back:hover{background:var(--line);}
18751 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18752 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18753 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18754 .pagination-info{font-size:13px;color:var(--muted);}
18755 .pagination-btns{display:flex;gap:6px;}
18756 .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;}
18757 .pg-btn:hover:not(:disabled){background:var(--line);}
18758 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18759 .pg-btn:disabled{opacity:.35;cursor:default;}
18760 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
18761 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18762 .site-footer a{color:var(--muted);}
18763 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18764 .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;}
18765 .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;}
18766 .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;}
18767 @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));}}
18768 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18769 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18770 .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;}
18771 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18772 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18773 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18774 .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);}
18775 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18776 .stat-chip:hover .stat-chip-tip{opacity:1;}
18777 .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;}
18778 .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;}
18779 .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%;}
18780 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
18781 .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;}
18782 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
18783 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
18784 .hidden{display:none!important;}
18785 .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%;}
18786 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
18787 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
18788 .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;}
18789 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
18790 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
18791 .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;}
18792 .scope-option:hover{background:var(--line);}
18793 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
18794 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
18795 .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;}
18796 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
18797 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
18798 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
18799 .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;}
18800 </style>
18801</head>
18802<body>
18803 <div class="background-watermarks" aria-hidden="true">
18804 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18805 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18806 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18807 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18808 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18809 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18810 </div>
18811 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18812 <div class="top-nav">
18813 <div class="top-nav-inner">
18814 <a class="brand" href="/">
18815 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18816 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
18817 </a>
18818 <div class="nav-right">
18819 <a class="nav-pill" href="/">Home</a>
18820 <div class="nav-dropdown">
18821 <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>
18822 <div class="nav-dropdown-menu">
18823 <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>
18824 </div>
18825 </div>
18826 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18827 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18828 <div class="nav-dropdown">
18829 <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>
18830 <div class="nav-dropdown-menu">
18831 <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>
18832 </div>
18833 </div>
18834 <div class="server-status-wrap" id="server-status-wrap">
18835 <div class="nav-pill server-online-pill" id="server-status-pill">
18836 <span class="status-dot" id="status-dot"></span>
18837 <span id="server-status-label">Server</span>
18838 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18839 </div>
18840 <div class="server-status-tip">
18841 OxideSLOC is running — accessible on your network.
18842 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18843 </div>
18844 </div>
18845 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18846 <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>
18847 </button>
18848 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18849 <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>
18850 <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>
18851 </button>
18852 </div>
18853 </div>
18854 </div>
18855
18856 <div class="page">
18857 <div class="watched-bar">
18858 <div class="watched-bar-left">
18859 <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>
18860 <span class="watched-label">Watched Folders</span>
18861 <div class="watched-chips">
18862 {% if server_mode %}
18863 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18864 {% else %}
18865 {% for dir in watched_dirs %}
18866 <span class="watched-chip">
18867 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18868 <form method="POST" action="/watched-dirs/remove" style="display:contents">
18869 <input type="hidden" name="folder_path" value="{{ dir }}">
18870 <input type="hidden" name="redirect_to" value="/compare-scans">
18871 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
18872 </form>
18873 </span>
18874 {% endfor %}
18875 {% if watched_dirs.is_empty() %}
18876 <span class="watched-none">No folders watched — click Choose to add one</span>
18877 {% endif %}
18878 {% endif %}
18879 </div>
18880 </div>
18881 {% if !server_mode %}
18882 <div class="watched-bar-right">
18883 <button type="button" class="btn" id="add-watched-btn">
18884 <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>
18885 Choose
18886 </button>
18887 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18888 <input type="hidden" name="redirect_to" value="/compare-scans">
18889 <button type="submit" class="btn">↻ Refresh</button>
18890 </form>
18891 </div>
18892 {% endif %}
18893 </div>
18894 {% if total_scans > 0 %}
18895 <div class="summary-strip">
18896 <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>
18897 <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>
18898 <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>
18899 <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>
18900 </div>
18901 {% endif %}
18902 <section class="panel">
18903 <div class="panel-header">
18904 <div>
18905 <h1>Compare Scans</h1>
18906 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
18907 </div>
18908 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
18909 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
18910 <button class="btn primary" id="compare-btn" disabled>
18911 <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>
18912 Compare <span class="sel-count" id="sel-count">0/2</span>
18913 </button>
18914 </div>
18915 </div>
18916 </div>
18917
18918 {% if entries.is_empty() %}
18919 <div class="empty-state">
18920 <strong>No scans yet</strong>
18921 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.
18922 </div>
18923 {% else %}
18924 <div class="filter-row">
18925 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
18926 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18927 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
18928 </div>
18929 <div class="scope-panel hidden" id="scope-panel">
18930 <div class="scope-panel-label">
18931 <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>
18932 Compare scope — choose what to include
18933 </div>
18934 <div class="scope-options" id="scope-options"></div>
18935 </div>
18936 {% if total_scans > 0 %}
18937 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
18938 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
18939 <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>
18940 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
18941 </div>
18942 </div>
18943 {% endif %}
18944 <div class="table-wrap">
18945 <table id="compare-table">
18946 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
18947 <thead>
18948 <tr id="compare-thead">
18949 <th><div class="col-resize-handle"></div></th>
18950 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18951 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18952 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
18953 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18954 <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>
18955 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18956 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18957 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18958 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18959 <th>Submodules<div class="col-resize-handle"></div></th>
18960 </tr>
18961 </thead>
18962 <tbody id="compare-tbody">
18963 {% for entry in entries %}
18964 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
18965 data-timestamp="{{ entry.timestamp }}"
18966 data-project="{{ entry.project_label }}"
18967 data-files="{{ entry.files_analyzed }}"
18968 data-code="{{ entry.code_lines }}"
18969 data-comments="{{ entry.comment_lines }}"
18970 data-blank="{{ entry.blank_lines }}"
18971 data-branch="{{ entry.git_branch }}"
18972 data-commit="{{ entry.git_commit }}"
18973 data-submodules="{{ entry.submodule_names_csv }}">
18974 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
18975 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18976 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18977 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
18978 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
18979 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18980 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18981 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18982 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
18983 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
18984 <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>
18985 </tr>
18986 {% endfor %}
18987 </tbody>
18988 </table>
18989 </div>
18990 <div class="pagination">
18991 <span class="pagination-info" id="pagination-info"></span>
18992 <div class="pagination-btns" id="pagination-btns"></div>
18993 <div class="flex-row">
18994 <span class="per-page-label">Show</span>
18995 <select class="per-page" id="per-page-sel">
18996 <option value="10">10 per page</option>
18997 <option value="25" selected>25 per page</option>
18998 <option value="50">50 per page</option>
18999 <option value="100">100 per page</option>
19000 </select>
19001 <span class="per-page-label" id="page-range-label"></span>
19002 </div>
19003 </div>
19004 {% endif %}
19005 </section>
19006 </div>
19007
19008 <footer class="site-footer">
19009 local code analysis - metrics, history and reports
19010 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19011 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19012 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19013 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19014 · <a href="/api-docs" rel="noopener">REST API</a>
19015 </footer>
19016
19017 <script nonce="{{ csp_nonce }}">
19018 (function () {
19019 // ── Theme ──────────────────────────────────────────────────────────────
19020 var storageKey = 'oxide-sloc-theme';
19021 var body = document.body;
19022 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19023 var toggle = document.getElementById('theme-toggle');
19024 if (toggle) toggle.addEventListener('click', function () {
19025 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19026 body.classList.toggle('dark-theme', next === 'dark');
19027 try { localStorage.setItem(storageKey, next); } catch(e) {}
19028 });
19029
19030 // ── State ─────────────────────────────────────────────────────────────
19031 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19032 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19033 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19034
19035 // ── Stat chips ────────────────────────────────────────────────────────
19036 (function() {
19037 var projects = {}, latestTs = '', latestRow = null;
19038 allRows.forEach(function(r) {
19039 var p = r.dataset.project || ''; if (p) projects[p] = true;
19040 var ts = r.dataset.timestamp || '';
19041 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19042 });
19043 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();}
19044 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>':'');}
19045 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19046 if (latestRow) {
19047 setChipVal('agg-code', latestRow.dataset.code);
19048 setChipVal('agg-files', latestRow.dataset.files);
19049 }
19050 })();
19051
19052 // ── Branch filter population ──────────────────────────────────────────
19053 (function() {
19054 var branches = {};
19055 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19056 var sel = document.getElementById('branch-filter');
19057 if (sel) Object.keys(branches).sort().forEach(function(b) {
19058 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19059 });
19060 })();
19061
19062 // ── Filter ────────────────────────────────────────────────────────────
19063 function getFilteredRows() {
19064 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19065 var branch = ((document.getElementById('branch-filter') || {}).value || '');
19066 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19067 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19068 if (branch && (r.dataset.branch || '') !== branch) return false;
19069 return true;
19070 });
19071 }
19072
19073 // ── Pagination ────────────────────────────────────────────────────────
19074 function renderPage() {
19075 var filtered = getFilteredRows();
19076 var total = filtered.length;
19077 var totalPages = Math.max(1, Math.ceil(total / perPage));
19078 currentPage = Math.min(currentPage, totalPages);
19079 var start = (currentPage - 1) * perPage;
19080 var end = Math.min(start + perPage, total);
19081 var shown = {};
19082 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19083 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19084 r.style.display = shown[r.dataset.run] ? '' : 'none';
19085 });
19086 var rl = document.getElementById('page-range-label');
19087 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19088 var info = document.getElementById('pagination-info');
19089 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19090 var btns = document.getElementById('pagination-btns');
19091 if (!btns) return;
19092 btns.innerHTML = '';
19093 function makeBtn(lbl, pg, active, disabled) {
19094 var b = document.createElement('button');
19095 b.className = 'pg-btn' + (active ? ' active' : '');
19096 b.textContent = lbl; b.disabled = disabled;
19097 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19098 return b;
19099 }
19100 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19101 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19102 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19103 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19104 }
19105
19106 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19107 window.applyFilters = function() { currentPage = 1; renderPage(); };
19108
19109 // ── Sorting ───────────────────────────────────────────────────────────
19110 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19111 function doSort(col, type, order) {
19112 var tbody = document.getElementById('compare-tbody');
19113 if (!tbody) return;
19114 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19115 rows.sort(function(a, b) {
19116 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19117 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19118 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19119 return va < vb ? 1 : va > vb ? -1 : 0;
19120 });
19121 rows.forEach(function(r) { tbody.appendChild(r); });
19122 currentPage = 1; renderPage();
19123 }
19124 sortHeaders.forEach(function(th) {
19125 th.addEventListener('click', function(e) {
19126 if (e.target.classList.contains('col-resize-handle')) return;
19127 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19128 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19129 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19130 th.classList.add('sort-' + sortOrder);
19131 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19132 doSort(col, type, sortOrder);
19133 });
19134 });
19135
19136 // Apply default sort (timestamp desc) on initial load
19137 (function() {
19138 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19139 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19140 })();
19141
19142 // ── Column resize ─────────────────────────────────────────────────────
19143 (function() {
19144 var table = document.getElementById('compare-table');
19145 if (!table) return;
19146 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19147 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19148 ths.forEach(function(th, i) {
19149 var handle = th.querySelector('.col-resize-handle');
19150 if (!handle || !cols[i]) return;
19151 var startX, startW;
19152 handle.addEventListener('mousedown', function(e) {
19153 e.stopPropagation(); e.preventDefault();
19154 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19155 handle.classList.add('dragging');
19156 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19157 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19158 document.addEventListener('mousemove', onMove);
19159 document.addEventListener('mouseup', onUp);
19160 });
19161 });
19162 })();
19163
19164 // ── Reset view ────────────────────────────────────────────────────────
19165 window.resetView = function() {
19166 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19167 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19168 sortCol = null; sortOrder = 'asc';
19169 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19170 var tbody = document.getElementById('compare-tbody');
19171 if (tbody) {
19172 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19173 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19174 rows.forEach(function(r) { tbody.appendChild(r); });
19175 }
19176 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19177 var table = document.getElementById('compare-table');
19178 currentPage = 1; renderPage();
19179 currentPage = 1; renderPage();
19180 };
19181
19182 renderPage();
19183
19184 // ── Row selection state ───────────────────────────────────────────────
19185 var selected = [];
19186 function updateCompareBtn() {
19187 var btn = document.getElementById('compare-btn');
19188 var cnt = document.getElementById('sel-count');
19189 if (!btn) return;
19190 btn.disabled = selected.length !== 2;
19191 if (cnt) cnt.textContent = selected.length + '/2';
19192 }
19193
19194 function toggleRow(row) {
19195 var vid = row.dataset.vid || row.dataset.run;
19196 var idx = selected.indexOf(vid);
19197 if (idx >= 0) {
19198 selected.splice(idx, 1);
19199 row.classList.remove('selected');
19200 var b = document.getElementById('badge-' + vid);
19201 if (b) b.textContent = '';
19202 } else {
19203 if (selected.length >= 2) return;
19204 selected.push(vid);
19205 row.classList.add('selected');
19206 }
19207 selected.forEach(function(v, i) {
19208 var b = document.getElementById('badge-' + v);
19209 if (b) b.textContent = i + 1;
19210 });
19211 updateCompareBtn();
19212 buildScopePanel();
19213 }
19214
19215 // ── Scope panel ───────────────────────────────────────────────────────
19216 var selectedScope = 'all';
19217
19218 function buildScopePanel() {
19219 var panel = document.getElementById('scope-panel');
19220 var opts = document.getElementById('scope-options');
19221 if (!panel || !opts) return;
19222 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19223
19224 // Collect union of submodules from both selected rows.
19225 var allSubs = {};
19226 selected.forEach(function(vid) {
19227 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19228 if (!row) return;
19229 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19230 });
19231 var subList = Object.keys(allSubs).sort();
19232 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19233
19234 panel.classList.remove('hidden');
19235 opts.innerHTML = '';
19236
19237 function makeOption(value, label, title) {
19238 var div = document.createElement('div');
19239 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19240 div.dataset.scopeValue = value;
19241 if (title) div.title = title;
19242 var radio = document.createElement('span');
19243 radio.className = 'scope-option-radio';
19244 var lbl = document.createElement('span');
19245 lbl.textContent = label;
19246 div.appendChild(radio);
19247 div.appendChild(lbl);
19248 div.addEventListener('click', function() {
19249 selectedScope = value;
19250 opts.querySelectorAll('.scope-option').forEach(function(o) {
19251 o.classList.toggle('selected', o.dataset.scopeValue === value);
19252 });
19253 });
19254 return div;
19255 }
19256
19257 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19258 var sep = document.createElement('span');
19259 sep.className = 'scope-option-sep';
19260 opts.appendChild(sep);
19261 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19262 subList.forEach(function(s) {
19263 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19264 });
19265 }
19266
19267 function doCompare() {
19268 if (selected.length !== 2) return;
19269 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19270 if (selectedScope === 'super') url += '&scope=super';
19271 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19272 window.location.href = url;
19273 }
19274
19275 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19276 var cbtn = document.getElementById('compare-btn');
19277 if (cbtn) cbtn.addEventListener('click', doCompare);
19278 var pfEl = document.getElementById('project-filter');
19279 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19280 var bfEl = document.getElementById('branch-filter');
19281 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19282 var rvBtn = document.getElementById('reset-view-btn');
19283 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19284 var ppSel = document.getElementById('per-page-sel');
19285 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19286
19287 var cmpTbody = document.getElementById('compare-tbody');
19288 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19289 var row = e.target.closest('.compare-row');
19290 if (row) toggleRow(row);
19291 });
19292
19293 (function randomizeWatermarks() {
19294 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19295 if (!wms.length) return;
19296 var placed = [];
19297 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;}
19298 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];}
19299 var half=Math.floor(wms.length/2);
19300 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;});
19301 })();
19302
19303 (function spawnCodeParticles() {
19304 var container = document.getElementById('code-particles');
19305 if (!container) return;
19306 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'];
19307 for (var i = 0; i < 38; i++) {
19308 (function(idx) {
19309 var el = document.createElement('span');
19310 el.className = 'code-particle';
19311 el.textContent = snippets[idx % snippets.length];
19312 var left = Math.random() * 94 + 2;
19313 var top = Math.random() * 88 + 6;
19314 var dur = (Math.random() * 10 + 9).toFixed(1);
19315 var delay = (Math.random() * 18).toFixed(1);
19316 var rot = (Math.random() * 26 - 13).toFixed(1);
19317 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19318 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';
19319 container.appendChild(el);
19320 })(i);
19321 }
19322 })();
19323
19324 // ── Watched folder picker ─────────────────────────────────────────────
19325 (function() {
19326 var btn = document.getElementById('add-watched-btn');
19327 if (!btn) return;
19328 btn.addEventListener('click', function() {
19329 fetch('/pick-directory?kind=reports')
19330 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19331 .then(function(data) {
19332 if (!data.cancelled && data.selected_path) {
19333 var form = document.createElement('form');
19334 form.method = 'POST';
19335 form.action = '/watched-dirs/add';
19336 var ri = document.createElement('input');
19337 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19338 var fi = document.createElement('input');
19339 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19340 form.appendChild(ri); form.appendChild(fi);
19341 document.body.appendChild(form);
19342 form.submit();
19343 }
19344 })
19345 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19346 });
19347 })();
19348
19349 // ── Submodule chip truncation ─────────────────────────────────────────
19350 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19351 var chips = cell.querySelectorAll('.submod-chip');
19352 var MAX = 4;
19353 if (chips.length <= MAX) return;
19354 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19355 var badge = document.createElement('span');
19356 badge.className = 'submod-overflow-badge';
19357 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19358 badge.textContent = '+' + (chips.length - MAX) + ' more';
19359 cell.appendChild(badge);
19360 cell.style.maxHeight = 'none';
19361 });
19362 })();
19363 </script>
19364 <script nonce="{{ csp_nonce }}">
19365 (function(){
19366 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'}];
19367 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);});}
19368 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19369 function init(){
19370 var btn=document.getElementById('settings-btn');if(!btn)return;
19371 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19372 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>';
19373 document.body.appendChild(m);
19374 var g=document.getElementById('scheme-grid');
19375 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);});
19376 var cl=document.getElementById('settings-close');
19377 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);
19378 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');});
19379 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19380 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19381 }
19382 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19383 }());
19384 </script>
19385 <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>
19386</body>
19387</html>
19388"##,
19389 ext = "html"
19390)]
19391struct CompareSelectTemplate {
19392 version: &'static str,
19393 entries: Vec<HistoryEntryRow>,
19394 total_scans: usize,
19395 watched_dirs: Vec<String>,
19396 csp_nonce: String,
19397 server_mode: bool,
19398}
19399
19400#[derive(Template)]
19403#[template(
19404 source = r##"
19405<!doctype html>
19406<html lang="en">
19407<head>
19408 <meta charset="utf-8">
19409 <meta name="viewport" content="width=device-width, initial-scale=1">
19410 <title>OxideSLOC | Scan Delta</title>
19411 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19412 <style nonce="{{ csp_nonce }}">
19413 :root {
19414 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
19415 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
19416 --nav:#283790; --nav-2:#013e6b;
19417 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
19418 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
19419 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
19420 }
19421 body.dark-theme {
19422 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
19423 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
19424 }
19425 *{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;}
19426 .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);}
19427 .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;}
19428 .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));}
19429 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19430 .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;}
19431 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
19432 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19433 @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; } }
19434 .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;}
19435 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19436 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19437 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19438 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19439 .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;}
19440 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19441 .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);}
19442 .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;}
19443 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19444 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19445 .settings-modal-body{padding:14px 16px 16px;}
19446 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19447 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19448 .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;}
19449 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19450 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19451 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19452 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19453 .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;}
19454 .tz-select:focus{border-color:var(--oxide);}
19455 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19456 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19457 .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;}
19458 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
19459 .hero-body{display:block;}
19460 .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;}
19461 .btn-back:hover{background:var(--line);}
19462 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
19463 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
19464 .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;}
19465 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
19466 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;}
19467 .muted{color:var(--muted);font-size:14px;}
19468 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
19469 .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;}
19470 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
19471 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
19472 .vpill-arrow{font-size:20px;color:var(--muted);}
19473 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
19474 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
19475 .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;}
19476 .delta-card.delta-card-wide{padding:22px 24px;}
19477 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
19478 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
19479 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
19480 .delta-card-from{font-size:15px;color:var(--muted);}
19481 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
19482 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
19483 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
19484 .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%;}
19485 .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;}
19486 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
19487 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
19488 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
19489 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
19490 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
19491 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
19492 .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;}
19493 .meta-card-commit:hover{color:var(--oxide);}
19494 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
19495 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
19496 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
19497 .meta-value{color:var(--text);font-size:13px;}
19498 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
19499 .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;}
19500 .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);}
19501 .delta-card:hover .dc-tip{display:block;}
19502 .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;}
19503 .export-btn:hover{background:var(--line);}
19504 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
19505 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
19506 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
19507 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
19508 .delta-card-change.zero{color:var(--muted);background:transparent;}
19509 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
19510 .delta-card-pct.pos{color:var(--pos);}
19511 .delta-card-pct.neg{color:var(--neg);}
19512 .delta-card-pct.zero{color:var(--muted);}
19513 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
19514 .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;}
19515 .insight-card.insight-flag{border-color:var(--oxide);}
19516 .insight-card:hover .dc-tip{display:block;}
19517 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
19518 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
19519 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
19520 .insight-label.flag{color:var(--oxide);}
19521 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
19522 .insight-val.pos{color:var(--pos);}
19523 .insight-val.neg{color:var(--neg);}
19524 .insight-val.high{color:#c0392a;}
19525 .insight-val.med{color:#926000;}
19526 .insight-val.low{color:var(--pos);}
19527 body.dark-theme .insight-val.high{color:#ff6b6b;}
19528 body.dark-theme .insight-val.med{color:#f0c060;}
19529 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
19530 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
19531 .fc-row{display:flex;align-items:center;gap:8px;}
19532 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
19533 .fc-label{color:var(--muted);}
19534 .fc-modified .fc-count{color:#926000;}
19535 .fc-added .fc-count{color:var(--pos);}
19536 .fc-removed .fc-count{color:var(--neg);}
19537 .fc-unchanged .fc-count{color:var(--muted);}
19538 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
19539 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
19540 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
19541 .chip.modified{background:#fff2d8;color:#926000;}
19542 .chip.added{background:#e8f5ed;color:#1a8f47;}
19543 .chip.removed{background:#fdeaea;color:#b33b3b;}
19544 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
19545 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
19546 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
19547 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
19548 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
19549 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
19550 .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;}
19551 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
19552 .tab-btn:hover:not(.active){background:var(--line);}
19553 .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;}
19554 .btn-reset:hover{background:var(--line);}
19555 .table-wrap{width:100%;overflow-x:auto;}
19556 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19557 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;}
19558 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19559 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19560 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19561 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19562 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19563 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19564 tr:last-child td{border-bottom:none;}
19565 tr.row-added td{background:rgba(26,143,71,0.06);}
19566 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
19567 tr.row-modified td{background:rgba(146,96,0,0.05);}
19568 tr.row-unchanged td{opacity:.6;}
19569 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
19570 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
19571 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
19572 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
19573 .status-badge.modified{background:#fff2d8;color:#926000;}
19574 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
19575 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
19576 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
19577 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
19578 .delta-val{font-weight:700;}
19579 .delta-val.pos{color:var(--pos);}
19580 .delta-val.neg{color:var(--neg);}
19581 .delta-val.zero{color:var(--muted);}
19582 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
19583 .from-to strong{color:var(--text);}
19584 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19585 .site-footer a{color:var(--muted);}
19586 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
19587 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
19588 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19589 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19590 .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;}
19591 .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;}
19592 .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;}
19593 @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));}}
19594 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
19595 .path-link:hover{color:var(--oxide-2);}
19596 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
19597 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
19598 a.vpill-id:hover{color:var(--oxide);}
19599 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
19600 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19601 .pagination-info{font-size:13px;color:var(--muted);}
19602 .pagination-btns{display:flex;gap:6px;}
19603 .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;}
19604 .pg-btn:hover:not(:disabled){background:var(--line);}
19605 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19606 .pg-btn:disabled{opacity:.35;cursor:default;}
19607 .per-page-label{font-size:13px;color:var(--muted);}
19608 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;}
19609 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19610 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
19611 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
19612 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
19613 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
19614 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
19615 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
19616 .tab-btn.tab-unchanged{color:var(--muted);}
19617 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
19618 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
19619 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
19620 .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;}
19621 .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;}
19622 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
19623 .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;}
19624 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19625 .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;}
19626 .submod-scope-btn:hover{background:var(--line);}
19627 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19628 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
19629 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
19630 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
19631 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
19632 body.dark-theme .ic-card{background:var(--surface-2);}
19633 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
19634 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
19635 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
19636 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
19637 #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;}
19638 </style>
19639</head>
19640<body>
19641 <div class="background-watermarks" aria-hidden="true">
19642 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19643 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19644 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19645 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19646 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19647 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19648 </div>
19649 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19650 <div class="top-nav">
19651 <div class="top-nav-inner">
19652 <a class="brand" href="/">
19653 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19654 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
19655 </a>
19656 <div class="nav-right">
19657 <a class="nav-pill" href="/">Home</a>
19658 <div class="nav-dropdown">
19659 <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>
19660 <div class="nav-dropdown-menu">
19661 <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>
19662 </div>
19663 </div>
19664 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19665 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19666 <div class="nav-dropdown">
19667 <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>
19668 <div class="nav-dropdown-menu">
19669 <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>
19670 </div>
19671 </div>
19672 <div class="server-status-wrap" id="server-status-wrap">
19673 <div class="nav-pill server-online-pill" id="server-status-pill">
19674 <span class="status-dot" id="status-dot"></span>
19675 <span id="server-status-label">Server</span>
19676 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19677 </div>
19678 <div class="server-status-tip">
19679 OxideSLOC is running — accessible on your network.
19680 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19681 </div>
19682 </div>
19683 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19684 <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>
19685 </button>
19686 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19687 <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>
19688 <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>
19689 </button>
19690 </div>
19691 </div>
19692 </div>
19693
19694 <div class="page">
19695 <section class="hero">
19696 <div class="hero-header">
19697 <div>
19698 <h1 class="delta-title">Scan Delta</h1>
19699 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
19700 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
19701 {% if let Some(sub) = active_submodule %}
19702 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
19703 {% else if super_scope_active %}
19704 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
19705 {% else %}
19706 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
19707 {% endif %}
19708 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
19709 </div>
19710 </div>
19711 <a class="btn-back" href="/compare-scans">
19712 <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>
19713 Compare Scans
19714 </a>
19715 </div>
19716 {% if has_any_submodule_data %}
19717 <div class="submod-scope-bar">
19718 <span class="submod-scope-label">
19719 <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>
19720 Scope:
19721 </span>
19722 <div class="submod-scope-divider"></div>
19723 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
19724 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
19725 title="All files — super-repo and all submodules combined">Full scan</a>
19726 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
19727 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
19728 title="Only files that are not part of any submodule">Super-repo only</a>
19729 {% for sub in submodule_options %}
19730 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
19731 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
19732 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
19733 {% endfor %}
19734 </div>
19735 {% endif %}
19736 <div class="hero-body">
19737 <div class="meta-strip">
19738 <div class="delta-card delta-card-meta">
19739 <div class="meta-card-header">
19740 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
19741 <div class="meta-card-project-col">
19742 <div class="meta-card-project">{{ project_name }}</div>
19743 {% if has_any_submodule_data %}
19744 {% if let Some(sub) = active_submodule %}
19745 <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>
19746 {% else if super_scope_active %}
19747 <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>
19748 {% else %}
19749 <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>
19750 {% endif %}
19751 {% endif %}
19752 </div>
19753 </div>
19754 {% if !baseline_git_commit.is_empty() %}
19755 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
19756 {% else %}
19757 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
19758 {% endif %}
19759 <div class="meta-card-rows">
19760 <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>
19761 <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>
19762 <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>
19763 <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>
19764 {% if let Some(tags) = baseline_git_tags %}
19765 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19766 {% endif %}
19767 </div>
19768 </div>
19769 <div class="delta-card delta-card-meta">
19770 <div class="meta-card-header">
19771 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
19772 <div class="meta-card-project-col">
19773 <div class="meta-card-project">{{ project_name }}</div>
19774 {% if has_any_submodule_data %}
19775 {% if let Some(sub) = active_submodule %}
19776 <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>
19777 {% else if super_scope_active %}
19778 <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>
19779 {% else %}
19780 <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>
19781 {% endif %}
19782 {% endif %}
19783 </div>
19784 </div>
19785 {% if !current_git_commit.is_empty() %}
19786 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
19787 {% else %}
19788 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
19789 {% endif %}
19790 <div class="meta-card-rows">
19791 <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>
19792 <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>
19793 <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>
19794 <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>
19795 {% if let Some(tags) = current_git_tags %}
19796 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19797 {% endif %}
19798 </div>
19799 </div>
19800 </div>
19801 <div class="delta-strip">
19802 <div class="delta-card">
19803 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
19804 <div class="delta-card-label">Code lines</div>
19805 <div class="delta-card-from">Before: {{ baseline_code }}</div>
19806 <div class="delta-card-to">{{ current_code }}</div>
19807 {% 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>
19808 {% 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>
19809 {% else %}<div class="delta-card-pct zero">±0%</div>
19810 {% endif %}
19811 </div>
19812 <div class="delta-card">
19813 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
19814 <div class="delta-card-label">Files analyzed</div>
19815 <div class="delta-card-from">Before: {{ baseline_files }}</div>
19816 <div class="delta-card-to">{{ current_files }}</div>
19817 {% 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>
19818 {% 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>
19819 {% else %}<div class="delta-card-pct zero">±0%</div>
19820 {% endif %}
19821 </div>
19822 <div class="delta-card">
19823 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
19824 <div class="delta-card-label">Comment lines</div>
19825 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
19826 <div class="delta-card-to">{{ current_comments }}</div>
19827 {% 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>
19828 {% 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>
19829 {% else %}<div class="delta-card-pct zero">±0%</div>
19830 {% endif %}
19831 </div>
19832 {{ coverage_delta_card|safe }}
19833 <div class="delta-card delta-card-wide">
19834 <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>
19835 <div class="delta-card-label">File changes</div>
19836 <div class="file-changes-grid">
19837 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
19838 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
19839 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
19840 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
19841 </div>
19842 </div>
19843 </div>
19844 <div class="insights-panel">
19845 <div class="insight-card">
19846 <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>
19847 <div class="insight-label">Lines Added</div>
19848 <div class="insight-val pos">+{{ code_lines_added }}</div>
19849 <div class="insight-sub">New or grown source lines</div>
19850 </div>
19851 <div class="insight-card">
19852 <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>
19853 <div class="insight-label">Lines Removed</div>
19854 <div class="insight-val neg">−{{ code_lines_removed }}</div>
19855 <div class="insight-sub">Deleted or shrunk source lines</div>
19856 </div>
19857 <div class="insight-card">
19858 <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>
19859 <div class="insight-label">Churn Rate</div>
19860 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
19861 <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>
19862 </div>
19863 {% if scope_flag %}
19864 <div class="insight-card insight-flag">
19865 <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>
19866 <div class="insight-label flag">Scope Signal</div>
19867 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
19868 <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>
19869 </div>
19870 {% endif %}
19871 </div>
19872 </div>
19873 </section>
19874
19875 <section class="panel" id="inline-charts-section">
19876 <h2>Scan Delta Charts</h2>
19877 <div class="ic-grid">
19878 <div class="ic-card">
19879 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
19880 <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>
19881 <div id="ic-c1"></div>
19882 </div>
19883 <div class="ic-card" id="ic-lang-card">
19884 <div class="ic-card-h2">Language Code Delta</div>
19885 <div id="ic-c3"></div>
19886 </div>
19887 <div class="ic-card">
19888 <div class="ic-card-h2">Delta by Metric</div>
19889 <div id="ic-c2"></div>
19890 </div>
19891 <div class="ic-card">
19892 <div class="ic-card-h2">File Change Distribution</div>
19893 <div id="ic-c4"></div>
19894 </div>
19895 </div>
19896 </section>
19897
19898 <section class="panel">
19899 <h2>File-level delta</h2>
19900 <div class="filter-tabs-row">
19901 <div class="filter-tabs">
19902 <button class="tab-btn tab-all active" data-filter="all">All</button>
19903 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
19904 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
19905 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
19906 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
19907 </div>
19908 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
19909 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
19910 <div class="export-group">
19911 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
19912 <button type="button" class="export-btn" id="delta-csv-btn">
19913 <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>
19914 CSV
19915 </button>
19916 <button type="button" class="export-btn" id="delta-xls-btn">
19917 <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>
19918 Excel
19919 </button>
19920 <button type="button" class="export-btn" id="delta-charts-btn">
19921 <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>
19922 Charts
19923 </button>
19924 </div>
19925 </div>
19926 </div>
19927
19928 <div class="table-wrap">
19929 <table id="delta-table">
19930 <colgroup>
19931 <col>
19932 <col>
19933 <col>
19934 <col>
19935 <col>
19936 <col>
19937 <col>
19938 </colgroup>
19939 <thead>
19940 <tr id="delta-thead">
19941 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19942 <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>
19943 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19944 <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>
19945 <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>
19946 <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>
19947 <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>
19948 </tr>
19949 </thead>
19950 <tbody id="delta-tbody">
19951 {% for row in file_rows %}
19952 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
19953 data-path="{{ row.relative_path }}"
19954 data-language="{{ row.language }}"
19955 data-baseline-code="{{ row.baseline_code }}"
19956 data-current-code="{{ row.current_code }}"
19957 data-code-delta="{{ row.code_delta_str }}"
19958 data-comment-delta="{{ row.comment_delta_str }}"
19959 data-total-delta="{{ row.total_delta_str }}"
19960 data-orig-idx="">
19961 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
19962 <td class="hide-sm">{{ row.language }}</td>
19963 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
19964 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
19965 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
19966 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
19967 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
19968 </tr>
19969 {% endfor %}
19970 </tbody>
19971 </table>
19972 </div>
19973 <div class="pagination">
19974 <span class="pagination-info" id="pg-info"></span>
19975 <div class="pagination-btns" id="pg-btns"></div>
19976 <div class="flex-row">
19977 <span class="per-page-label">Show</span>
19978 <select class="per-page" id="per-page-sel">
19979 <option value="10">10 per page</option>
19980 <option value="25" selected>25 per page</option>
19981 <option value="50">50 per page</option>
19982 <option value="100">100 per page</option>
19983 </select>
19984 <span class="per-page-label" id="pg-range-label"></span>
19985 </div>
19986 </div>
19987 </section>
19988 </div>
19989
19990 <div id="ic-tt"></div>
19991
19992 <footer class="site-footer">
19993 local code analysis - metrics, history and reports
19994 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19995 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19996 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19997 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19998 · <a href="/api-docs" rel="noopener">REST API</a>
19999 </footer>
20000
20001 <script nonce="{{ csp_nonce }}">
20002 (function () {
20003 var storageKey = 'oxide-sloc-theme';
20004 var body = document.body;
20005 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20006 var toggle = document.getElementById('theme-toggle');
20007 if (toggle) toggle.addEventListener('click', function () {
20008 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20009 body.classList.toggle('dark-theme', next === 'dark');
20010 try { localStorage.setItem(storageKey, next); } catch(e) {}
20011 });
20012
20013 (function randomizeWatermarks() {
20014 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20015 if (!wms.length) return;
20016 var placed = [];
20017 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;}
20018 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];}
20019 var half=Math.floor(wms.length/2);
20020 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;});
20021 })();
20022
20023 (function spawnCodeParticles() {
20024 var container = document.getElementById('code-particles');
20025 if (!container) return;
20026 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'];
20027 for (var i = 0; i < 38; i++) {
20028 (function(idx) {
20029 var el = document.createElement('span');
20030 el.className = 'code-particle';
20031 el.textContent = snippets[idx % snippets.length];
20032 var left = Math.random() * 94 + 2;
20033 var top = Math.random() * 88 + 6;
20034 var dur = (Math.random() * 10 + 9).toFixed(1);
20035 var delay = (Math.random() * 18).toFixed(1);
20036 var rot = (Math.random() * 26 - 13).toFixed(1);
20037 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20038 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';
20039 container.appendChild(el);
20040 })(i);
20041 }
20042 })();
20043 })();
20044
20045 var activeStatusFilter = 'all';
20046 var deltaPerPage = 25, deltaCurrPage = 1;
20047
20048 function openFolder(path) {
20049 fetch('/open-path?path=' + encodeURIComponent(path))
20050 .then(function (r) { return r.json(); })
20051 .then(function (d) {
20052 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20053 })
20054 .catch(function () {});
20055 }
20056
20057 function getDeltaFilteredRows() {
20058 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20059 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20060 });
20061 }
20062
20063 function renderDeltaPage() {
20064 var filtered = getDeltaFilteredRows();
20065 var total = filtered.length;
20066 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20067 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20068 var start = (deltaCurrPage - 1) * deltaPerPage;
20069 var end = Math.min(start + deltaPerPage, total);
20070 var shownSet = {};
20071 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20072 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20073 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20074 });
20075 var rl = document.getElementById('pg-range-label');
20076 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20077 var info = document.getElementById('pg-info');
20078 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20079 var btns = document.getElementById('pg-btns');
20080 if (!btns) return;
20081 btns.innerHTML = '';
20082 if (totalPages <= 1) return;
20083 function makeBtn(lbl, pg, active, disabled) {
20084 var b = document.createElement('button');
20085 b.className = 'pg-btn' + (active ? ' active' : '');
20086 b.textContent = lbl; b.disabled = disabled;
20087 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20088 return b;
20089 }
20090 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20091 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20092 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20093 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20094 }
20095
20096 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20097
20098 function filterRows(status, btn) {
20099 activeStatusFilter = status;
20100 deltaCurrPage = 1;
20101 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20102 b.classList.remove('active');
20103 });
20104 if (btn) btn.classList.add('active');
20105 renderDeltaPage();
20106 }
20107
20108 // ── Sorting ──────────────────────────────────────────────────────────────
20109 var sortCol = null, sortOrder = 'asc';
20110 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20111 (function() {
20112 var tbody = document.getElementById('delta-tbody');
20113 if (!tbody) return;
20114 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20115 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20116 })();
20117
20118 function parseDeltaNum(str) {
20119 if (!str || str === '—') return 0;
20120 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20121 }
20122
20123 sortHeaders.forEach(function(th) {
20124 th.addEventListener('click', function(e) {
20125 if (e.target.classList.contains('col-resize-handle')) return;
20126 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20127 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20128 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20129 th.classList.add('sort-' + sortOrder);
20130 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20131 var tbody = document.getElementById('delta-tbody');
20132 if (!tbody) return;
20133 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20134 rows.sort(function(a, b) {
20135 var va, vb;
20136 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20137 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20138 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20139 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20140 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20141 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20142 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20143 else { va = ''; vb = ''; }
20144 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20145 return va < vb ? 1 : va > vb ? -1 : 0;
20146 });
20147 rows.forEach(function(r) { tbody.appendChild(r); });
20148 deltaCurrPage = 1;
20149 renderDeltaPage();
20150 var activeBtn = document.querySelector('.tab-btn.active');
20151 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20152 if (activeBtn) activeBtn.classList.add('active');
20153 });
20154 });
20155
20156 // ── Column resize ─────────────────────────────────────────────────────────
20157 (function() {
20158 var table = document.getElementById('delta-table');
20159 if (!table) return;
20160 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20161 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20162 ths.forEach(function(th, i) {
20163 var handle = th.querySelector('.col-resize-handle');
20164 if (!handle || !cols[i]) return;
20165 var startX, startW;
20166 handle.addEventListener('mousedown', function(e) {
20167 e.stopPropagation(); e.preventDefault();
20168 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20169 handle.classList.add('dragging');
20170 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20171 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20172 document.addEventListener('mousemove', onMove);
20173 document.addEventListener('mouseup', onUp);
20174 });
20175 });
20176 })();
20177
20178 // ── Reset ─────────────────────────────────────────────────────────────────
20179 window.resetDeltaTable = function() {
20180 sortCol = null; sortOrder = 'asc';
20181 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20182 var tbody = document.getElementById('delta-tbody');
20183 if (tbody) {
20184 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20185 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20186 rows.forEach(function(r) { tbody.appendChild(r); });
20187 }
20188 var table = document.getElementById('delta-table');
20189 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20190 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20191 activeStatusFilter = 'all';
20192 deltaCurrPage = 1;
20193 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20194 var allBtn = document.querySelector('.tab-btn');
20195 if (allBtn) allBtn.classList.add('active');
20196 renderDeltaPage();
20197 };
20198
20199 renderDeltaPage();
20200
20201 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20202 (function() {
20203 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20204 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20205 });
20206 var resetBtn = document.getElementById('delta-reset-btn');
20207 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20208 var csvBtn = document.getElementById('delta-csv-btn');
20209 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20210 var xlsBtn = document.getElementById('delta-xls-btn');
20211 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20212 var chartsBtn = document.getElementById('delta-charts-btn');
20213 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20214 var ppSel = document.getElementById('per-page-sel');
20215 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20216 var pathLink = document.getElementById('project-path-link');
20217 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20218 })();
20219
20220 // ── Export helpers ────────────────────────────────────────────────────────
20221 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
20222 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20223 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);}
20224 function slocMakeXlsx(fname,sd,dr){
20225 var enc=new TextEncoder();
20226 // CRC-32 table
20227 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;}
20228 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;}
20229 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20230 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20231 // Shared string table
20232 var ss=[],si={};
20233 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20234 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20235 // Worksheet builder — each WS() call gets its own row counter R
20236 function WS(){
20237 var R=0,buf=[];
20238 function cl(c){return String.fromCharCode(65+c);}
20239 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20240 '<v>'+S(v)+'</v></c>';}
20241 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20242 (st?' s="'+st+'"':'')+'>'+
20243 '<v>'+(+v)+'</v></c>';}
20244 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20245 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20246 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20247 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20248 '<sheetFormatPr defaultRowHeight="15"/>'+
20249 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20250 return{sc:sc,nc:nc,row:row,xml:xml};
20251 }
20252 // Language breakdown
20253 var lm={};
20254 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;});
20255 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20256 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20257 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20258 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20259 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20260 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20261 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):'';}
20262 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20263 // Summary sheet
20264 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20265 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20266 r1(s1(0,proj,2));
20267 r1(s1(0,sd.bts+' → '+sd.cts,2));
20268 r1('');
20269 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20270 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))));
20271 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))));
20272 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))));
20273 r1('');
20274 r1(s1(0,'FILE CHANGES',8));
20275 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20276 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20277 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20278 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20279 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20280 if(langs.length){
20281 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20282 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20283 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)));});
20284 }
20285 r1('');r1(s1(0,'SCAN METADATA',8));
20286 r1(s1(1,_blabel)+s1(2,_clabel));
20287 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20288 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20289 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"/>');
20290 // File Delta sheet
20291 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20292 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));
20293 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)));});
20294 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20295 // Shared strings XML
20296 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20297 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20298 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20299 // XLSX file map
20300 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20301 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>',
20302 '_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>',
20303 '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>',
20304 '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>',
20305 '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>',
20306 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20307 // ZIP packer — STORED (no compression), compatible with all XLSX readers
20308 var zparts=[],zcds=[],zoff=0,znf=0;
20309 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20310 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20311 ].forEach(function(name){
20312 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20313 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]);
20314 var entry=new Uint8Array(lha.length+nb.length+sz);
20315 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20316 zparts.push(entry);
20317 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));
20318 var cde=new Uint8Array(cda.length+nb.length);
20319 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20320 zcds.push(cde);zoff+=entry.length;znf++;
20321 });
20322 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20323 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]);
20324 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20325 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20326 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20327 zout.set(new Uint8Array(ea),zpos);
20328 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20329 var xurl=URL.createObjectURL(xblob);
20330 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20331 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20332 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20333 }
20334 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;');}
20335 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20336 function getExportFilename(ext){return _exportBase+'.'+ext;}
20337
20338 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 }}'};
20339 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;}
20340 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20341 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20342 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20343 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20344 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):'';}
20345 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20346 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)]];}
20347 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20348 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;}
20349 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20350 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20351
20352 // ── Chart HTML report ─────────────────────────────────────────────────────
20353 function slocChartReport(fname, sd, dr) {
20354 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20355 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20356 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20357 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();}
20358 function px(n){return Math.round(n);}
20359 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20360 // Language map
20361 var lm={};
20362 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;});
20363 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20364
20365 // Builds onmouse* attrs for interactive tooltip on each SVG element
20366 function barTT(label,val){
20367 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
20368 }
20369
20370 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
20371 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'}];
20372 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20373 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
20374 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
20375 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20376 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"/>';}
20377 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20378 c1mets.forEach(function(m,i){
20379 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20380 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20381 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>';
20382 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))+'/>';
20383 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>';
20384 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))+'/>';
20385 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>';
20386 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>';
20387 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>';
20388 });
20389 c1+='</svg>';
20390
20391 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
20392 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'}];
20393 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20394 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
20395 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
20396 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20397 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20398 mets.forEach(function(m,i){
20399 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
20400 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
20401 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
20402 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>';
20403 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
20404 if(bw>=52){
20405 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>';
20406 }else{
20407 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
20408 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>';
20409 }
20410 });
20411 c2+='</svg>';
20412
20413 // ── Chart 3: Language Code Delta ─────────────────────────────────────
20414 var c3='';
20415 if(langs.length){
20416 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20417 var C3W=550,c3LW=124,c3FW=52;
20418 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
20419 var L3rH=30,C3H=langs.length*L3rH+20;
20420 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20421 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20422 langs.forEach(function(l,i){
20423 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
20424 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
20425 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
20426 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20427 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':''))+'/>';
20428 if(bw>=48){
20429 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>';
20430 }else{
20431 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
20432 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>';
20433 }
20434 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>';
20435 });
20436 c3+='</svg>';
20437 }
20438
20439 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
20440 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;});
20441 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20442 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
20443 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20444 var ang=-Math.PI/2;
20445 segs.forEach(function(s){
20446 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20447 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
20448 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
20449 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
20450 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
20451 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)+'%')+'/>';
20452 ang+=sw;
20453 });
20454 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>';
20455 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20456 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>';});
20457 c4+='</svg>';
20458
20459 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
20460 var ttJs='var tt=document.getElementById("ox-tt");'+
20461 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
20462 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
20463 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
20464 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
20465 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
20466 'function oxHT(){tt.style.display="none";}';
20467
20468 // body max-width keeps charts from inflating beyond design dimensions on
20469 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
20470 // each chart's height blows up proportionally, breaking the one-page layout.
20471 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;}'+
20472 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
20473 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
20474 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
20475 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
20476 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
20477 'svg{display:block;}'+
20478 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
20479 '#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;}'+
20480 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
20481 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
20482 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
20483 '<div id="ox-tt"><\/div>'+
20484 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
20485 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
20486 '<div class="two-col">'+
20487 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
20488 '<div class="leg">'+
20489 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
20490 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
20491 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
20492 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
20493 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
20494 '<\/div>'+
20495 '<div class="two-col">'+
20496 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
20497 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
20498 '<\/div>'+
20499 '<script>'+ttJs+'<\/script>'+
20500 '<\/body><\/html>';
20501 slocDownload(html, fname, 'text/html;charset=utf-8;');
20502 }
20503 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
20504 // ── Inline delta charts ────────────────────────────────────────────────────
20505 var _icTT=document.getElementById('ic-tt');
20506 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
20507 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';};
20508 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
20509 (function(){
20510 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
20511 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20512 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();}
20513 function px(n){return Math.round(n);}
20514 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20515 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
20516 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);});}
20517 var dr=getDeltaExportRows(),sd=_sd,lm={};
20518 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;});
20519 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20520 // Chart 1: Baseline vs Current grouped bars
20521 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'}];
20522 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20523 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;
20524 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20525 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"/>';}
20526 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20527 c1mets.forEach(function(m,i){
20528 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20529 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20530 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>';
20531 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"/>';
20532 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>';
20533 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"/>';
20534 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>';
20535 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>';
20536 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>';
20537 });
20538 c1+='</svg>';
20539 // Chart 2: Delta by Metric
20540 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'}];
20541 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20542 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;
20543 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20544 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20545 mets.forEach(function(m,i){
20546 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);
20547 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>';
20548 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
20549 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>';}
20550 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>';}
20551 });
20552 c2+='</svg>';
20553 // Chart 3: Language Code Delta
20554 var c3='';
20555 if(langs.length){
20556 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20557 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;
20558 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20559 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20560 langs.forEach(function(l,i){
20561 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);
20562 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20563 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"/>';
20564 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>';}
20565 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>';}
20566 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>';
20567 });
20568 c3+='</svg>';
20569 }
20570 // Chart 4: File Change Donut
20571 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;});
20572 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20573 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;
20574 if(segs.length===1){
20575 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
20576 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
20577 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
20578 } else {
20579 segs.forEach(function(s){
20580 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20581 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);
20582 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);
20583 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"/>';
20584 ang+=sw;
20585 });
20586 }
20587 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>';
20588 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20589 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>';});
20590 c4+='</svg>';
20591 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
20592 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
20593 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);}
20594 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
20595 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
20596 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
20597 })();
20598 </script>
20599 <script nonce="{{ csp_nonce }}">
20600 (function(){
20601 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'}];
20602 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);});}
20603 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20604 function init(){
20605 var btn=document.getElementById('settings-btn');if(!btn)return;
20606 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20607 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>';
20608 document.body.appendChild(m);
20609 var g=document.getElementById('scheme-grid');
20610 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);});
20611 var cl=document.getElementById('settings-close');
20612 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);
20613 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');});
20614 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20615 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20616 }
20617 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20618 }());
20619 </script>
20620 <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>
20621</body>
20622</html>
20623"##,
20624 ext = "html"
20625)]
20626#[allow(clippy::struct_excessive_bools)]
20628struct CompareTemplate {
20629 version: &'static str,
20630 project_label: String,
20631 baseline_git_commit: String,
20632 current_git_commit: String,
20633 baseline_run_id: String,
20634 current_run_id: String,
20635 baseline_run_id_short: String,
20636 current_run_id_short: String,
20637 baseline_timestamp: String,
20638 baseline_timestamp_utc_ms: i64,
20639 current_timestamp: String,
20640 current_timestamp_utc_ms: i64,
20641 project_path: String,
20642 baseline_code: u64,
20643 current_code: u64,
20644 code_lines_delta_str: String,
20645 code_lines_delta_class: String,
20646 baseline_files: u64,
20647 current_files: u64,
20648 files_analyzed_delta_str: String,
20649 files_analyzed_delta_class: String,
20650 baseline_comments: u64,
20651 current_comments: u64,
20652 comment_lines_delta_str: String,
20653 comment_lines_delta_class: String,
20654 code_lines_pct_str: String,
20655 files_analyzed_pct_str: String,
20656 comment_lines_pct_str: String,
20657 code_lines_added: i64,
20658 code_lines_removed: i64,
20659 new_scope: bool,
20661 churn_rate_str: String,
20662 churn_rate_class: String,
20663 scope_flag: bool,
20664 files_added: usize,
20665 files_removed: usize,
20666 files_modified: usize,
20667 files_unchanged: usize,
20668 file_rows: Vec<CompareFileDeltaRow>,
20669 baseline_git_author: Option<String>,
20670 current_git_author: Option<String>,
20671 baseline_git_branch: String,
20672 current_git_branch: String,
20673 baseline_git_tags: Option<String>,
20674 current_git_tags: Option<String>,
20675 baseline_git_commit_date: Option<String>,
20676 current_git_commit_date: Option<String>,
20677 project_name: String,
20678 submodule_options: Vec<String>,
20680 has_any_submodule_data: bool,
20682 active_submodule: Option<String>,
20684 super_scope_active: bool,
20686 csp_nonce: String,
20687 coverage_delta_card: String,
20689}
20690
20691#[derive(Template)]
20694#[template(
20695 source = r##"
20696<!doctype html>
20697<html lang="en">
20698<head>
20699 <meta charset="utf-8">
20700 <meta name="viewport" content="width=device-width, initial-scale=1">
20701 <title>OxideSLOC | Sign In</title>
20702 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20703 <style nonce="{{ csp_nonce }}">
20704 :root {
20705 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
20706 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
20707 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
20708 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
20709 }
20710 *{box-sizing:border-box;}
20711 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);}
20712 .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);}
20713 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
20714 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
20715 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
20716 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20717 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20718 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20719 .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;}
20720 @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));}}
20721 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
20722 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
20723 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20724 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
20725 .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;}
20726 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
20727 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;}
20728 input[type=password]:focus{border-color:var(--oxide);}
20729 .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;}
20730 .btn:hover{opacity:.88;}
20731 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
20732 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
20733 </style>
20734</head>
20735<body>
20736 <div class="background-watermarks" aria-hidden="true">
20737 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20738 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20739 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20740 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20741 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20742 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20743 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20744 </div>
20745 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20746<nav class="top-nav">
20747 <a class="brand" href="/">
20748 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
20749 <span class="brand-title">OxideSLOC</span>
20750 </a>
20751</nav>
20752<main class="page">
20753 <div class="card">
20754 <h1>Sign In</h1>
20755 <p class="subtitle">Enter the API key printed when the server started.</p>
20756 {% if has_error %}
20757 <div class="error">Incorrect API key — please try again.</div>
20758 {% endif %}
20759 <form method="POST" action="/auth/login">
20760 <input type="hidden" name="next" value="{{ next_url|e }}">
20761 <label for="key">API Key</label>
20762 <input id="key" type="password" name="key" autocomplete="current-password"
20763 placeholder="Paste your API key here" autofocus>
20764 <button type="submit" class="btn">Sign In</button>
20765 </form>
20766 <p class="hint">
20767 The API key was printed in the terminal when the server started.<br>
20768 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
20769 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
20770 </p>
20771 </div>
20772</main>
20773<script nonce="{{ csp_nonce }}">
20774(function() {
20775 (function randomizeWatermarks() {
20776 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20777 if (!wms.length) return;
20778 var placed = [];
20779 function tooClose(top, left) {
20780 for (var i = 0; i < placed.length; i++) {
20781 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
20782 if (dt < 16 && dl < 12) return true;
20783 }
20784 return false;
20785 }
20786 function pick(leftBand) {
20787 for (var attempt = 0; attempt < 50; attempt++) {
20788 var top = Math.random() * 88 + 2;
20789 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20790 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20791 }
20792 var top = Math.random() * 88 + 2;
20793 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20794 placed.push([top, left]); return [top, left];
20795 }
20796 var half = Math.floor(wms.length / 2);
20797 wms.forEach(function (img, i) {
20798 var pos = pick(i < half);
20799 var size = Math.floor(Math.random() * 100 + 120);
20800 var rot = (Math.random() * 360).toFixed(1);
20801 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
20802 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;
20803 });
20804 })();
20805 (function spawnCodeParticles() {
20806 var container = document.getElementById('code-particles');
20807 if (!container) return;
20808 var snippets = [
20809 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20810 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20811 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20812 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20813 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20814 ];
20815 var count = 38;
20816 for (var i = 0; i < count; i++) {
20817 (function(idx) {
20818 var el = document.createElement('span');
20819 el.className = 'code-particle';
20820 el.textContent = snippets[idx % snippets.length];
20821 var left = Math.random() * 94 + 2;
20822 var top = Math.random() * 88 + 6;
20823 var dur = (Math.random() * 10 + 9).toFixed(1);
20824 var delay = (Math.random() * 18).toFixed(1);
20825 var rot = (Math.random() * 26 - 13).toFixed(1);
20826 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20827 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
20828 container.appendChild(el);
20829 })(i);
20830 }
20831 })();
20832})();
20833</script>
20834</body>
20835</html>
20836"##,
20837 ext = "html"
20838)]
20839pub(crate) struct LoginTemplate {
20840 pub(crate) csp_nonce: String,
20841 pub(crate) has_error: bool,
20842 pub(crate) next_url: String,
20843 pub(crate) lockout_threshold: u32,
20844}
20845
20846#[derive(Template)]
20849#[template(
20850 source = r##"
20851<!doctype html>
20852<html lang="en">
20853<head>
20854 <meta charset="utf-8">
20855 <meta name="viewport" content="width=device-width, initial-scale=1">
20856 <title>OxideSLOC — REST API Reference</title>
20857 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20858 <style nonce="{{ csp_nonce }}">
20859 :root {
20860 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20861 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20862 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
20863 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20864 --success:#16a34a;
20865 }
20866 body.dark-theme {
20867 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
20868 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
20869 }
20870 *{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;}
20871 .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);}
20872 .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;}
20873 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
20874 .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));}
20875 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
20876 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
20877 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
20878 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20879 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20880 @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; } }
20881 .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;}
20882 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
20883 .nav-pill.active{background:rgba(255,255,255,0.22);}
20884 .nav-dropdown{position:relative;display:inline-flex;}
20885 .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;}
20886 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
20887 .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;}
20888 .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;}
20889 .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);}
20890 .nav-dropdown-menu a:last-child{border-bottom:none;}
20891 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
20892 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
20893 .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;}
20894 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20895 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20896 .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;}
20897 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20898 .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);}
20899 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
20900 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
20901 .settings-modal-body{padding:14px 16px 16px;}
20902 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20903 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20904 .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;}
20905 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20906 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20907 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20908 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20909 .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;}
20910 .tz-select:focus{border-color:var(--oxide);}
20911 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
20912 .page-header{margin-bottom:28px;}
20913 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
20914 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
20915 .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;}
20916 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
20917 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
20918 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
20919 .callout strong{font-weight:800;}
20920 .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;}
20921 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
20922 .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;}
20923 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
20924 .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;}
20925 body.dark-theme .base-url-value{color:var(--accent);}
20926 .section{margin-bottom:36px;}
20927 .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);}
20928 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
20929 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
20930 .ep-header:hover{background:var(--surface-2);}
20931 .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;}
20932 .method.get{background:#dcfce7;color:#166534;}
20933 .method.post{background:#dbeafe;color:#1e40af;}
20934 .method.delete{background:#fee2e2;color:#991b1b;}
20935 body.dark-theme .method.get{background:#14532d;color:#86efac;}
20936 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
20937 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
20938 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
20939 .ep-path .param{color:var(--oxide-2);}
20940 body.dark-theme .ep-path .param{color:var(--oxide);}
20941 .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;}
20942 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
20943 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
20944 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
20945 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
20946 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
20947 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
20948 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
20949 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
20950 .ep-card.open .chevron{transform:rotate(180deg);}
20951 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
20952 .ep-card.open .ep-body{display:block;}
20953 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
20954 .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;}
20955 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
20956 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
20957 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
20958 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
20959 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);}
20960 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
20961 table.params tr:last-child td{border-bottom:none;}
20962 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
20963 .pt-type{color:var(--muted-2);font-size:12px;}
20964 .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;}
20965 .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;}
20966 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
20967 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
20968 details.schema{margin-bottom:14px;}
20969 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;}
20970 details.schema summary:hover{color:var(--text);}
20971 .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;}
20972 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
20973 .curl-wrap{position:relative;}
20974 .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;}
20975 .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;}
20976 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
20977 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
20978 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
20979 .webhook-note a{color:var(--accent-2);text-decoration:none;}
20980 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20981 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20982 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20983 .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;}
20984 @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));}}
20985 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20986 .site-footer a{color:var(--muted);}
20987 </style>
20988</head>
20989<body>
20990 <div class="background-watermarks" aria-hidden="true">
20991 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20992 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20993 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20994 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20995 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20996 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20997 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20998 </div>
20999 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21000 <div class="top-nav">
21001 <div class="top-nav-inner">
21002 <a class="brand" href="/">
21003 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21004 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21005 </a>
21006 <div class="nav-right">
21007 <a class="nav-pill" href="/">Home</a>
21008 <div class="nav-dropdown">
21009 <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>
21010 <div class="nav-dropdown-menu">
21011 <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>
21012 </div>
21013 </div>
21014 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21015 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21016 <div class="nav-dropdown">
21017 <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>
21018 <div class="nav-dropdown-menu">
21019 <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>
21020 </div>
21021 </div>
21022 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21023 <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>
21024 </button>
21025 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21026 <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>
21027 <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>
21028 </button>
21029 </div>
21030 </div>
21031 </div>
21032
21033 <div class="page">
21034 <div class="page-header">
21035 <h1 class="page-title">REST API Reference</h1>
21036 <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>
21037 </div>
21038
21039 {% if has_api_key %}
21040 <div class="callout key-set">
21041 <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>
21042 <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>
21043 </div>
21044 {% else %}
21045 <div class="callout no-key">
21046 <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>
21047 <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>
21048 </div>
21049 {% endif %}
21050
21051 <div class="base-url-bar">
21052 <span class="base-url-label">Base URL</span>
21053 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21054 </div>
21055
21056 <!-- Health -->
21057 <div class="section">
21058 <h2 class="section-title">Health & Status</h2>
21059 <div class="ep-card">
21060 <div class="ep-header">
21061 <span class="method get">GET</span>
21062 <span class="ep-path">/healthz</span>
21063 <span class="auth-badge public">Public</span>
21064 <span class="ep-desc">Server liveness check</span>
21065 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21066 </div>
21067 <div class="ep-body">
21068 <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>
21069 <p class="params-heading">Response</p>
21070 <div class="schema-block">200 OK
21071Content-Type: text/plain
21072
21073ok</div>
21074 <p class="curl-heading">Example</p>
21075 <div class="curl-wrap">
21076 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21077 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21078 </div>
21079 </div>
21080 </div>
21081 </div>
21082
21083 <!-- Badges -->
21084 <div class="section">
21085 <h2 class="section-title">Badges</h2>
21086 <div class="ep-card">
21087 <div class="ep-header">
21088 <span class="method get">GET</span>
21089 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21090 <span class="auth-badge public">Public</span>
21091 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21092 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21093 </div>
21094 <div class="ep-body">
21095 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21096 <p class="params-heading">Path Parameters</p>
21097 <table class="params">
21098 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21099 <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>
21100 </table>
21101 <p class="curl-heading">Example</p>
21102 <div class="curl-wrap">
21103 <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>
21104 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21105 </div>
21106 </div>
21107 </div>
21108 </div>
21109
21110 <!-- Metrics -->
21111 <div class="section">
21112 <h2 class="section-title">Metrics</h2>
21113
21114 <div class="ep-card">
21115 <div class="ep-header">
21116 <span class="method get">GET</span>
21117 <span class="ep-path">/api/metrics/latest</span>
21118 <span class="auth-badge protected">Protected</span>
21119 <span class="ep-desc">Latest scan metrics (JSON)</span>
21120 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21121 </div>
21122 <div class="ep-body">
21123 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21124 <details class="schema"><summary>Response schema</summary>
21125<div class="schema-block">{
21126 "run_id": string, // UUID
21127 "timestamp": string, // ISO-8601 UTC
21128 "project": string, // scanned root path
21129 "summary": {
21130 "files_analyzed": number,
21131 "files_skipped": number,
21132 "code_lines": number,
21133 "comment_lines": number,
21134 "blank_lines": number,
21135 "total_physical_lines": number,
21136 "functions": number,
21137 "classes": number,
21138 "variables": number,
21139 "imports": number
21140 },
21141 "languages": [
21142 { "name": string, "files": number, "code_lines": number,
21143 "comment_lines": number, "blank_lines": number,
21144 "functions": number, "classes": number,
21145 "variables": number, "imports": number }
21146 ]
21147}</div></details>
21148 <p class="curl-heading">Example</p>
21149 <div class="curl-wrap">
21150 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21151 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21152 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21153 </div>
21154 </div>
21155 </div>
21156
21157 <div class="ep-card">
21158 <div class="ep-header">
21159 <span class="method get">GET</span>
21160 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21161 <span class="auth-badge protected">Protected</span>
21162 <span class="ep-desc">Metrics for a specific run</span>
21163 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21164 </div>
21165 <div class="ep-body">
21166 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21167 <p class="params-heading">Path Parameters</p>
21168 <table class="params">
21169 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21170 <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>
21171 </table>
21172 <p class="curl-heading">Example</p>
21173 <div class="curl-wrap">
21174 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21175 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
21176 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21177 </div>
21178 </div>
21179 </div>
21180
21181 <div class="ep-card">
21182 <div class="ep-header">
21183 <span class="method get">GET</span>
21184 <span class="ep-path">/api/metrics/history</span>
21185 <span class="auth-badge protected">Protected</span>
21186 <span class="ep-desc">Paginated scan history</span>
21187 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21188 </div>
21189 <div class="ep-body">
21190 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21191 <p class="params-heading">Query Parameters</p>
21192 <table class="params">
21193 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21194 <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>
21195 <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>
21196 </table>
21197 <details class="schema"><summary>Response schema</summary>
21198<div class="schema-block">[{
21199 "run_id": string,
21200 "timestamp": string, // ISO-8601 UTC
21201 "commit": string | null,
21202 "branch": string | null,
21203 "tags": string[],
21204 "code_lines": number,
21205 "comment_lines": number,
21206 "blank_lines": number,
21207 "physical_lines": number,
21208 "files_analyzed": number,
21209 "project_label": string,
21210 "html_url": string | null
21211}]</div></details>
21212 <p class="curl-heading">Example</p>
21213 <div class="curl-wrap">
21214 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21215 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21216 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21217 </div>
21218 </div>
21219 </div>
21220
21221 <div class="ep-card">
21222 <div class="ep-header">
21223 <span class="method get">GET</span>
21224 <span class="ep-path">/api/project-history</span>
21225 <span class="auth-badge protected">Protected</span>
21226 <span class="ep-desc">Project-level scan summary</span>
21227 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21228 </div>
21229 <div class="ep-body">
21230 <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>
21231 <p class="params-heading">Query Parameters</p>
21232 <table class="params">
21233 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21234 <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>
21235 </table>
21236 <details class="schema"><summary>Response schema</summary>
21237<div class="schema-block">{
21238 "scan_count": number,
21239 "last_scan_id": string | null,
21240 "last_scan_timestamp": string | null, // ISO-8601
21241 "last_scan_code_lines": number | null,
21242 "last_git_branch": string | null,
21243 "last_git_commit": string | null
21244}</div></details>
21245 <p class="curl-heading">Example</p>
21246 <div class="curl-wrap">
21247 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21248 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21249 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21250 </div>
21251 </div>
21252 </div>
21253
21254 <div class="ep-card">
21255 <div class="ep-header">
21256 <span class="method get">GET</span>
21257 <span class="ep-path">/api/metrics/submodules</span>
21258 <span class="auth-badge protected">Protected</span>
21259 <span class="ep-desc">List known git submodules across scans</span>
21260 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21261 </div>
21262 <div class="ep-body">
21263 <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>
21264 <p class="params-heading">Query Parameters</p>
21265 <table class="params">
21266 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21267 <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>
21268 </table>
21269 <details class="schema"><summary>Response schema</summary>
21270<div class="schema-block">[{
21271 "name": string, // submodule name
21272 "relative_path": string // path relative to the project root
21273}]</div></details>
21274 <p class="curl-heading">Example</p>
21275 <div class="curl-wrap">
21276 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21277 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21278 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21279 </div>
21280 </div>
21281 </div>
21282 </div>
21283
21284 <!-- Async Run Status -->
21285 <div class="section">
21286 <h2 class="section-title">Async Run Status</h2>
21287
21288 <div class="ep-card">
21289 <div class="ep-header">
21290 <span class="method get">GET</span>
21291 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21292 <span class="auth-badge protected">Protected</span>
21293 <span class="ep-desc">Poll scan completion</span>
21294 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21295 </div>
21296 <div class="ep-body">
21297 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21298 <details class="schema"><summary>Response schema</summary>
21299<div class="schema-block">// Running
21300{ "state": "running", "elapsed_secs": number }
21301
21302// Complete
21303{ "state": "complete", "run_id": string }
21304
21305// Failed
21306{ "state": "failed", "message": string }</div></details>
21307 <p class="curl-heading">Example</p>
21308 <div class="curl-wrap">
21309 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21310 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
21311 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21312 </div>
21313 </div>
21314 </div>
21315
21316 <div class="ep-card">
21317 <div class="ep-header">
21318 <span class="method get">GET</span>
21319 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21320 <span class="auth-badge protected">Protected</span>
21321 <span class="ep-desc">Poll PDF generation readiness</span>
21322 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21323 </div>
21324 <div class="ep-body">
21325 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21326 <details class="schema"><summary>Response schema</summary>
21327<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21328 <p class="curl-heading">Example</p>
21329 <div class="curl-wrap">
21330 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21331 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
21332 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21333 </div>
21334 </div>
21335 </div>
21336
21337 <div class="ep-card">
21338 <div class="ep-header">
21339 <span class="method post">POST</span>
21340 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21341 <span class="auth-badge protected">Protected</span>
21342 <span class="ep-desc">Cancel a running scan</span>
21343 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21344 </div>
21345 <div class="ep-body">
21346 <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>
21347 <p class="curl-heading">Example</p>
21348 <div class="curl-wrap">
21349 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21350 -H "Authorization: Bearer $SLOC_API_KEY" \
21351 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
21352 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21353 </div>
21354 </div>
21355 </div>
21356 </div>
21357
21358 <!-- Scan Profiles -->
21359 <div class="section">
21360 <h2 class="section-title">Scan Profiles</h2>
21361
21362 <div class="ep-card">
21363 <div class="ep-header">
21364 <span class="method get">GET</span>
21365 <span class="ep-path">/api/scan-profiles</span>
21366 <span class="auth-badge protected">Protected</span>
21367 <span class="ep-desc">List saved scan profiles</span>
21368 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21369 </div>
21370 <div class="ep-body">
21371 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
21372 <details class="schema"><summary>Response schema</summary>
21373<div class="schema-block">{
21374 "profiles": [{
21375 "id": string, // UUID
21376 "name": string,
21377 "created_at": string, // ISO-8601
21378 "params": object
21379 }]
21380}</div></details>
21381 <p class="curl-heading">Example</p>
21382 <div class="curl-wrap">
21383 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21384 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21385 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
21386 </div>
21387 </div>
21388 </div>
21389
21390 <div class="ep-card">
21391 <div class="ep-header">
21392 <span class="method post">POST</span>
21393 <span class="ep-path">/api/scan-profiles</span>
21394 <span class="auth-badge protected">Protected</span>
21395 <span class="ep-desc">Save a scan profile</span>
21396 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21397 </div>
21398 <div class="ep-body">
21399 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
21400 <p class="params-heading">Request Body (application/json)</p>
21401 <table class="params">
21402 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21403 <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>
21404 <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>
21405 </table>
21406 <details class="schema"><summary>Response schema</summary>
21407<div class="schema-block">{ "ok": true }</div></details>
21408 <p class="curl-heading">Example</p>
21409 <div class="curl-wrap">
21410 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
21411 -H "Authorization: Bearer $SLOC_API_KEY" \
21412 -H "Content-Type: application/json" \
21413 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
21414 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21415 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
21416 </div>
21417 </div>
21418 </div>
21419
21420 <div class="ep-card">
21421 <div class="ep-header">
21422 <span class="method delete">DELETE</span>
21423 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
21424 <span class="auth-badge protected">Protected</span>
21425 <span class="ep-desc">Delete a scan profile</span>
21426 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21427 </div>
21428 <div class="ep-body">
21429 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
21430 <p class="params-heading">Path Parameters</p>
21431 <table class="params">
21432 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21433 <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>
21434 </table>
21435 <details class="schema"><summary>Response schema</summary>
21436<div class="schema-block">{ "ok": true }</div></details>
21437 <p class="curl-heading">Example</p>
21438 <div class="curl-wrap">
21439 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
21440 -H "Authorization: Bearer $SLOC_API_KEY" \
21441 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
21442 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
21443 </div>
21444 </div>
21445 </div>
21446 </div>
21447
21448 <!-- Scheduled Scans -->
21449 <div class="section">
21450 <h2 class="section-title">Scheduled Scans</h2>
21451
21452 <div class="ep-card">
21453 <div class="ep-header">
21454 <span class="method get">GET</span>
21455 <span class="ep-path">/api/schedules</span>
21456 <span class="auth-badge protected">Protected</span>
21457 <span class="ep-desc">List configured schedules</span>
21458 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21459 </div>
21460 <div class="ep-body">
21461 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
21462 <p class="curl-heading">Example</p>
21463 <div class="curl-wrap">
21464 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21465 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21466 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
21467 </div>
21468 </div>
21469 </div>
21470
21471 <div class="ep-card">
21472 <div class="ep-header">
21473 <span class="method post">POST</span>
21474 <span class="ep-path">/api/schedules</span>
21475 <span class="auth-badge protected">Protected</span>
21476 <span class="ep-desc">Create a schedule</span>
21477 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21478 </div>
21479 <div class="ep-body">
21480 <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>
21481 <p class="curl-heading">Example</p>
21482 <div class="curl-wrap">
21483 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
21484 -H "Authorization: Bearer $SLOC_API_KEY" \
21485 -H "Content-Type: application/json" \
21486 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
21487 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21488 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
21489 </div>
21490 </div>
21491 </div>
21492
21493 <div class="ep-card">
21494 <div class="ep-header">
21495 <span class="method delete">DELETE</span>
21496 <span class="ep-path">/api/schedules</span>
21497 <span class="auth-badge protected">Protected</span>
21498 <span class="ep-desc">Delete a schedule</span>
21499 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21500 </div>
21501 <div class="ep-body">
21502 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
21503 <p class="curl-heading">Example</p>
21504 <div class="curl-wrap">
21505 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
21506 -H "Authorization: Bearer $SLOC_API_KEY" \
21507 -H "Content-Type: application/json" \
21508 -d '{"id":"<schedule_id>"}' \
21509 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21510 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
21511 </div>
21512 </div>
21513 </div>
21514 </div>
21515
21516 <!-- Git Browser -->
21517 <div class="section">
21518 <h2 class="section-title">Git Browser</h2>
21519
21520 <div class="ep-card">
21521 <div class="ep-header">
21522 <span class="method get">GET</span>
21523 <span class="ep-path">/api/git/refs</span>
21524 <span class="auth-badge protected">Protected</span>
21525 <span class="ep-desc">List git refs for a repository</span>
21526 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21527 </div>
21528 <div class="ep-body">
21529 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
21530 <p class="params-heading">Query Parameters</p>
21531 <table class="params">
21532 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21533 <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>
21534 </table>
21535 <p class="curl-heading">Example</p>
21536 <div class="curl-wrap">
21537 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21538 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
21539 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
21540 </div>
21541 </div>
21542 </div>
21543
21544 <div class="ep-card">
21545 <div class="ep-header">
21546 <span class="method get">GET</span>
21547 <span class="ep-path">/api/git/scan-ref</span>
21548 <span class="auth-badge protected">Protected</span>
21549 <span class="ep-desc">SLOC-scan a specific git ref</span>
21550 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21551 </div>
21552 <div class="ep-body">
21553 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
21554 <p class="params-heading">Query Parameters</p>
21555 <table class="params">
21556 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21557 <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>
21558 <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>
21559 </table>
21560 <p class="curl-heading">Example</p>
21561 <div class="curl-wrap">
21562 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21563 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
21564 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
21565 </div>
21566 </div>
21567 </div>
21568
21569 <div class="ep-card">
21570 <div class="ep-header">
21571 <span class="method get">GET</span>
21572 <span class="ep-path">/api/git/compare-refs</span>
21573 <span class="auth-badge protected">Protected</span>
21574 <span class="ep-desc">Compare SLOC across two git refs</span>
21575 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21576 </div>
21577 <div class="ep-body">
21578 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
21579 <p class="params-heading">Query Parameters</p>
21580 <table class="params">
21581 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21582 <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>
21583 <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>
21584 <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>
21585 </table>
21586 <p class="curl-heading">Example</p>
21587 <div class="curl-wrap">
21588 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21589 "<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>
21590 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
21591 </div>
21592 </div>
21593 </div>
21594 </div>
21595
21596 <!-- Webhooks -->
21597 <div class="section">
21598 <h2 class="section-title">Webhooks</h2>
21599 <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>
21600
21601 <div class="ep-card">
21602 <div class="ep-header">
21603 <span class="method post">POST</span>
21604 <span class="ep-path">/webhooks/github</span>
21605 <span class="auth-badge hmac">HMAC</span>
21606 <span class="ep-desc">GitHub push event receiver</span>
21607 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21608 </div>
21609 <div class="ep-body">
21610 <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>
21611 <p class="params-heading">Required Headers</p>
21612 <table class="params">
21613 <tr><th>Header</th><th>Value</th></tr>
21614 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
21615 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
21616 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21617 </table>
21618 </div>
21619 </div>
21620
21621 <div class="ep-card">
21622 <div class="ep-header">
21623 <span class="method post">POST</span>
21624 <span class="ep-path">/webhooks/gitlab</span>
21625 <span class="auth-badge hmac">HMAC</span>
21626 <span class="ep-desc">GitLab push event receiver</span>
21627 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21628 </div>
21629 <div class="ep-body">
21630 <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>
21631 <p class="params-heading">Required Headers</p>
21632 <table class="params">
21633 <tr><th>Header</th><th>Value</th></tr>
21634 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
21635 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
21636 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21637 </table>
21638 </div>
21639 </div>
21640
21641 <div class="ep-card">
21642 <div class="ep-header">
21643 <span class="method post">POST</span>
21644 <span class="ep-path">/webhooks/bitbucket</span>
21645 <span class="auth-badge hmac">HMAC</span>
21646 <span class="ep-desc">Bitbucket push event receiver</span>
21647 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21648 </div>
21649 <div class="ep-body">
21650 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
21651 <p class="params-heading">Required Headers</p>
21652 <table class="params">
21653 <tr><th>Header</th><th>Value</th></tr>
21654 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
21655 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21656 </table>
21657 </div>
21658 </div>
21659 </div>
21660
21661 <!-- Config -->
21662 <div class="section">
21663 <h2 class="section-title">Config Import / Export</h2>
21664
21665 <div class="ep-card">
21666 <div class="ep-header">
21667 <span class="method get">GET</span>
21668 <span class="ep-path">/export-config</span>
21669 <span class="auth-badge protected">Protected</span>
21670 <span class="ep-desc">Export server configuration as JSON</span>
21671 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21672 </div>
21673 <div class="ep-body">
21674 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
21675 <p class="curl-heading">Example</p>
21676 <div class="curl-wrap">
21677 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21678 -o config.json \
21679 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
21680 <button class="curl-copy-btn" data-target="c-export">Copy</button>
21681 </div>
21682 </div>
21683 </div>
21684
21685 <div class="ep-card">
21686 <div class="ep-header">
21687 <span class="method post">POST</span>
21688 <span class="ep-path">/import-config</span>
21689 <span class="auth-badge protected">Protected</span>
21690 <span class="ep-desc">Import server configuration</span>
21691 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21692 </div>
21693 <div class="ep-body">
21694 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
21695 <p class="curl-heading">Example</p>
21696 <div class="curl-wrap">
21697 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
21698 -H "Authorization: Bearer $SLOC_API_KEY" \
21699 -H "Content-Type: application/json" \
21700 -d @config.json \
21701 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
21702 <button class="curl-copy-btn" data-target="c-import">Copy</button>
21703 </div>
21704 </div>
21705 </div>
21706 </div>
21707
21708 <!-- CI Ingest -->
21709 <div class="section">
21710 <h2 class="section-title">CI Ingest</h2>
21711
21712 <div class="ep-card">
21713 <div class="ep-header">
21714 <span class="method post">POST</span>
21715 <span class="ep-path">/api/ingest</span>
21716 <span class="auth-badge protected">Protected</span>
21717 <span class="ep-desc">Push a pre-computed scan result from CI</span>
21718 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21719 </div>
21720 <div class="ep-body">
21721 <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>
21722 <p class="params-heading">Query Parameters</p>
21723 <table class="params">
21724 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21725 <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>
21726 </table>
21727 <p class="params-heading">Request Body (application/json)</p>
21728 <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>
21729 <details class="schema"><summary>Response schema</summary>
21730<div class="schema-block">// 201 Created
21731{
21732 "run_id": string, // UUID of the ingested run
21733 "view_url": string // relative URL to the report page
21734}</div></details>
21735 <p class="curl-heading">Example</p>
21736 <div class="curl-wrap">
21737 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
21738 -H "Authorization: Bearer $SLOC_API_KEY" \
21739 -H "Content-Type: application/json" \
21740 -d @result.json \
21741 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
21742 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
21743 </div>
21744 </div>
21745 </div>
21746 </div>
21747
21748 <!-- Artifact Download -->
21749 <div class="section">
21750 <h2 class="section-title">Artifact Download</h2>
21751
21752 <div class="ep-card">
21753 <div class="ep-header">
21754 <span class="method get">GET</span>
21755 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
21756 <span class="auth-badge protected">Protected</span>
21757 <span class="ep-desc">Download or view a scan artifact</span>
21758 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21759 </div>
21760 <div class="ep-body">
21761 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
21762 <p class="params-heading">Path Parameters</p>
21763 <table class="params">
21764 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21765 <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>
21766 <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>
21767 </table>
21768 <p class="params-heading">Query Parameters</p>
21769 <table class="params">
21770 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21771 <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>
21772 </table>
21773 <p class="curl-heading">Example — download JSON result</p>
21774 <div class="curl-wrap">
21775 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21776 -o result.json \
21777 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
21778 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
21779 </div>
21780 </div>
21781 </div>
21782 </div>
21783
21784 <!-- Embed Widget -->
21785 <div class="section">
21786 <h2 class="section-title">Embed Widget</h2>
21787
21788 <div class="ep-card">
21789 <div class="ep-header">
21790 <span class="method get">GET</span>
21791 <span class="ep-path">/embed/summary</span>
21792 <span class="auth-badge protected">Protected</span>
21793 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
21794 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21795 </div>
21796 <div class="ep-body">
21797 <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>
21798 <p class="params-heading">Query Parameters</p>
21799 <table class="params">
21800 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21801 <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>
21802 <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>
21803 </table>
21804 <p class="curl-heading">Example</p>
21805 <div class="curl-wrap">
21806 <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"
21807 width="460" height="260" style="border:none"></iframe></pre>
21808 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
21809 </div>
21810 </div>
21811 </div>
21812 </div>
21813
21814 <!-- Confluence Integration -->
21815 <div class="section">
21816 <h2 class="section-title">Confluence Integration</h2>
21817
21818 <div class="ep-card">
21819 <div class="ep-header">
21820 <span class="method get">GET</span>
21821 <span class="ep-path">/api/confluence/config</span>
21822 <span class="auth-badge protected">Protected</span>
21823 <span class="ep-desc">Get current Confluence configuration</span>
21824 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21825 </div>
21826 <div class="ep-body">
21827 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
21828 <details class="schema"><summary>Response schema</summary>
21829<div class="schema-block">{
21830 "configured": boolean,
21831 "tier": "cloud" | "server",
21832 "base_url": string,
21833 "username": string,
21834 "api_token_set": boolean,
21835 "space_key": string,
21836 "parent_page_id": string | null,
21837 "schedule_auto_post": { "<schedule_id>": boolean }
21838}</div></details>
21839 <p class="curl-heading">Example</p>
21840 <div class="curl-wrap">
21841 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21842 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
21843 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
21844 </div>
21845 </div>
21846 </div>
21847
21848 <div class="ep-card">
21849 <div class="ep-header">
21850 <span class="method post">POST</span>
21851 <span class="ep-path">/api/confluence/config</span>
21852 <span class="auth-badge protected">Protected</span>
21853 <span class="ep-desc">Save Confluence configuration</span>
21854 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21855 </div>
21856 <div class="ep-body">
21857 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
21858 <p class="params-heading">Request Body (application/json)</p>
21859 <table class="params">
21860 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21861 <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>
21862 <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>
21863 <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>
21864 <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>
21865 <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>
21866 <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>
21867 <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>
21868 </table>
21869 <details class="schema"><summary>Response schema</summary>
21870<div class="schema-block">{ "ok": true }</div></details>
21871 <p class="curl-heading">Example</p>
21872 <div class="curl-wrap">
21873 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
21874 -H "Authorization: Bearer $SLOC_API_KEY" \
21875 -H "Content-Type: application/json" \
21876 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
21877 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
21878 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
21879 </div>
21880 </div>
21881 </div>
21882
21883 <div class="ep-card">
21884 <div class="ep-header">
21885 <span class="method post">POST</span>
21886 <span class="ep-path">/api/confluence/test</span>
21887 <span class="auth-badge protected">Protected</span>
21888 <span class="ep-desc">Test Confluence connection</span>
21889 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21890 </div>
21891 <div class="ep-body">
21892 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
21893 <details class="schema"><summary>Response schema</summary>
21894<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
21895 <p class="curl-heading">Example</p>
21896 <div class="curl-wrap">
21897 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
21898 -H "Authorization: Bearer $SLOC_API_KEY" \
21899 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
21900 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
21901 </div>
21902 </div>
21903 </div>
21904
21905 <div class="ep-card">
21906 <div class="ep-header">
21907 <span class="method post">POST</span>
21908 <span class="ep-path">/api/confluence/post</span>
21909 <span class="auth-badge protected">Protected</span>
21910 <span class="ep-desc">Publish a scan report to Confluence</span>
21911 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21912 </div>
21913 <div class="ep-body">
21914 <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>
21915 <p class="params-heading">Request Body (application/json)</p>
21916 <table class="params">
21917 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21918 <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>
21919 <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>
21920 <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>
21921 </table>
21922 <details class="schema"><summary>Response schema</summary>
21923<div class="schema-block">// 200 OK
21924{ "ok": true, "page_id": string }
21925
21926// 400 / 502 on error
21927{ "ok": false, "error": string }</div></details>
21928 <p class="curl-heading">Example</p>
21929 <div class="curl-wrap">
21930 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
21931 -H "Authorization: Bearer $SLOC_API_KEY" \
21932 -H "Content-Type: application/json" \
21933 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
21934 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
21935 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
21936 </div>
21937 </div>
21938 </div>
21939
21940 <div class="ep-card">
21941 <div class="ep-header">
21942 <span class="method get">GET</span>
21943 <span class="ep-path">/api/confluence/wiki-markup</span>
21944 <span class="auth-badge protected">Protected</span>
21945 <span class="ep-desc">Get Confluence wiki markup for a run</span>
21946 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21947 </div>
21948 <div class="ep-body">
21949 <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>
21950 <p class="params-heading">Query Parameters</p>
21951 <table class="params">
21952 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21953 <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>
21954 </table>
21955 <p class="curl-heading">Example</p>
21956 <div class="curl-wrap">
21957 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21958 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
21959 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
21960 </div>
21961 </div>
21962 </div>
21963 </div>
21964
21965 <!-- Authentication -->
21966 <div class="section">
21967 <h2 class="section-title">Authentication</h2>
21968 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
21969
21970 <div class="ep-card">
21971 <div class="ep-header">
21972 <span class="method get">GET</span>
21973 <span class="ep-path">/auth/login</span>
21974 <span class="auth-badge public">Public</span>
21975 <span class="ep-desc">Login page</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">Returns the HTML login form. Redirects to <code>/</code> immediately when no API key is configured on the server.</p>
21980 <p class="params-heading">Query Parameters</p>
21981 <table class="params">
21982 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21983 <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>
21984 <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>
21985 </table>
21986 </div>
21987 </div>
21988
21989 <div class="ep-card">
21990 <div class="ep-header">
21991 <span class="method post">POST</span>
21992 <span class="ep-path">/auth/login</span>
21993 <span class="auth-badge public">Public</span>
21994 <span class="ep-desc">Submit credentials and get a session cookie</span>
21995 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21996 </div>
21997 <div class="ep-body">
21998 <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>
21999 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22000 <table class="params">
22001 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22002 <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>
22003 <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>
22004 </table>
22005 <p class="curl-heading">Example</p>
22006 <div class="curl-wrap">
22007 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22008 -d "key=$SLOC_API_KEY&next=/" \
22009 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22010 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22011 </div>
22012 </div>
22013 </div>
22014 </div>
22015
22016 <!-- Coverage Suggestion -->
22017 <div class="section">
22018 <h2 class="section-title">Coverage Suggestion</h2>
22019
22020 <div class="ep-card">
22021 <div class="ep-header">
22022 <span class="method get">GET</span>
22023 <span class="ep-path">/api/suggest-coverage</span>
22024 <span class="auth-badge protected">Protected</span>
22025 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22026 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22027 </div>
22028 <div class="ep-body">
22029 <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>
22030 <p class="params-heading">Query Parameters</p>
22031 <table class="params">
22032 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22033 <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>
22034 </table>
22035 <details class="schema"><summary>Response schema</summary>
22036<div class="schema-block">{
22037 "found": string | null, // absolute path to the coverage file, if detected
22038 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22039 "hint": string | null // shell command to generate coverage if not found
22040}</div></details>
22041 <p class="curl-heading">Example</p>
22042 <div class="curl-wrap">
22043 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22044 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22045 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22046 </div>
22047 </div>
22048 </div>
22049 </div>
22050
22051 </div>
22052
22053 <footer class="site-footer">
22054 local code analysis - metrics, history and reports
22055 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22056 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22057 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22058 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22059 · <a href="/api-docs" rel="noopener">REST API</a>
22060 </footer>
22061
22062 <script nonce="{{ csp_nonce }}">
22063 (function () {
22064 var base = window.location.origin;
22065 document.getElementById('base-url').textContent = base;
22066 document.querySelectorAll('.base-url-slot').forEach(function (el) {
22067 el.textContent = base;
22068 });
22069
22070 document.querySelectorAll('.ep-header').forEach(function (hdr) {
22071 hdr.addEventListener('click', function () {
22072 hdr.closest('.ep-card').classList.toggle('open');
22073 });
22074 });
22075
22076 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22077 btn.addEventListener('click', function () {
22078 var targetId = btn.dataset.target;
22079 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22080 if (!pre) return;
22081 navigator.clipboard.writeText(pre.textContent).then(function () {
22082 btn.textContent = 'Copied!';
22083 btn.classList.add('copied');
22084 setTimeout(function () {
22085 btn.textContent = 'Copy';
22086 btn.classList.remove('copied');
22087 }, 2000);
22088 });
22089 });
22090 });
22091
22092 var storageKey = 'oxide-sloc-theme';
22093 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22094 var themeBtn = document.getElementById('theme-toggle');
22095 if (themeBtn) {
22096 themeBtn.addEventListener('click', function () {
22097 var dark = document.body.classList.toggle('dark-theme');
22098 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22099 });
22100 }
22101 (function() {
22102 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'}];
22103 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);});}
22104 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22105 var btn=document.getElementById('settings-btn');if(!btn)return;
22106 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22107 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>';
22108 document.body.appendChild(m);
22109 var g=document.getElementById('scheme-grid');
22110 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);});
22111 var cl=document.getElementById('settings-close');
22112 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);
22113 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');});
22114 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22115 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22116 })();
22117 (function randomizeWatermarks() {
22118 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22119 if (!wms.length) return;
22120 var placed = [];
22121 function tooClose(top, left) {
22122 for (var i = 0; i < placed.length; i++) {
22123 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22124 if (dt < 16 && dl < 12) return true;
22125 }
22126 return false;
22127 }
22128 function pick(leftBand) {
22129 for (var attempt = 0; attempt < 50; attempt++) {
22130 var top = Math.random() * 88 + 2;
22131 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22132 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22133 }
22134 var top = Math.random() * 88 + 2;
22135 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22136 placed.push([top, left]); return [top, left];
22137 }
22138 var half = Math.floor(wms.length / 2);
22139 wms.forEach(function (img, i) {
22140 var pos = pick(i < half);
22141 var size = Math.floor(Math.random() * 100 + 120);
22142 var rot = (Math.random() * 360).toFixed(1);
22143 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22144 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;
22145 });
22146 })();
22147 (function spawnCodeParticles() {
22148 var container = document.getElementById('code-particles');
22149 if (!container) return;
22150 var snippets = [
22151 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22152 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22153 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22154 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22155 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22156 ];
22157 var count = 38;
22158 for (var i = 0; i < count; i++) {
22159 (function(idx) {
22160 var el = document.createElement('span');
22161 el.className = 'code-particle';
22162 el.textContent = snippets[idx % snippets.length];
22163 var left = Math.random() * 94 + 2;
22164 var top = Math.random() * 88 + 6;
22165 var dur = (Math.random() * 10 + 9).toFixed(1);
22166 var delay = (Math.random() * 18).toFixed(1);
22167 var rot = (Math.random() * 26 - 13).toFixed(1);
22168 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22169 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22170 container.appendChild(el);
22171 })(i);
22172 }
22173 })();
22174 }());
22175 </script>
22176</body>
22177</html>
22178"##,
22179 ext = "html"
22180)]
22181struct ApiDocsTemplate {
22182 has_api_key: bool,
22183 csp_nonce: String,
22184 version: &'static str,
22185}