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 git_commit_url = run
4150 .git_remote_url
4151 .as_deref()
4152 .zip(run.git_commit_long.as_deref())
4153 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4154 let scan_performed_by = format!(
4155 "{} / {}",
4156 run.environment.initiator_username, run.environment.initiator_hostname
4157 );
4158 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc, false);
4159 let generated_display = fmt_la_time_meta(run.tool.timestamp_utc, true);
4160 let os_display = format!(
4161 "{} / {}",
4162 run.environment.operating_system, run.environment.architecture
4163 );
4164 let test_count = run.summary_totals.test_count;
4165
4166 let template = ResultTemplate {
4167 version: env!("CARGO_PKG_VERSION"),
4168 report_title: run.effective_configuration.reporting.report_title.clone(),
4169 project_path: project_path.clone(),
4170 output_dir: display_path(&artifacts.output_dir),
4171 run_id: run_id.to_owned(),
4172 run_id_short: run_id
4173 .split('-')
4174 .next_back()
4175 .unwrap_or(run_id)
4176 .chars()
4177 .take(7)
4178 .collect(),
4179 files_analyzed,
4180 files_skipped,
4181 physical_lines,
4182 code_lines,
4183 comment_lines,
4184 blank_lines,
4185 mixed_lines,
4186 functions,
4187 classes,
4188 variables,
4189 imports,
4190 html_url: artifacts
4191 .html_path
4192 .as_ref()
4193 .map(|_| format!("/runs/html/{run_id}")),
4194 pdf_url: artifacts
4195 .pdf_path
4196 .as_ref()
4197 .map(|_| format!("/runs/pdf/{run_id}")),
4198 json_url: artifacts
4199 .json_path
4200 .as_ref()
4201 .map(|_| format!("/runs/json/{run_id}")),
4202 html_download_url: artifacts
4203 .html_path
4204 .as_ref()
4205 .map(|_| format!("/runs/html/{run_id}?download=1")),
4206 pdf_download_url: artifacts
4207 .pdf_path
4208 .as_ref()
4209 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4210 json_download_url: artifacts
4211 .json_path
4212 .as_ref()
4213 .map(|_| format!("/runs/json/{run_id}?download=1")),
4214 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4215 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4216 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4217 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4218 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4219 prev_fa_str,
4220 prev_fs_str,
4221 prev_pl_str,
4222 prev_cl_str,
4223 prev_cml_str,
4224 prev_bl_str,
4225 delta_fa_str,
4226 delta_fa_class,
4227 delta_fs_str,
4228 delta_fs_class,
4229 delta_pl_str,
4230 delta_pl_class,
4231 delta_cl_str,
4232 delta_cl_class,
4233 delta_cml_str,
4234 delta_cml_class,
4235 delta_bl_str,
4236 delta_bl_class,
4237 delta_lines_added,
4238 delta_lines_removed,
4239 delta_lines_net_str,
4240 delta_lines_net_class,
4241 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4242 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4243 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4244 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4245 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4246 d.file_deltas
4247 .iter()
4248 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4249 .map(|f| {
4250 #[allow(clippy::cast_sign_loss)]
4251 let n = f.current_code as u64;
4252 n
4253 })
4254 .sum()
4255 }),
4256 git_branch,
4257 git_commit,
4258 git_commit_long,
4259 git_author,
4260 git_commit_url,
4261 scan_performed_by,
4262 scan_time_display,
4263 generated_display,
4264 os_display,
4265 test_count,
4266 current_scan_number: prev_scan_count + 1,
4267 prev_scan_count,
4268 submodule_rows: run
4269 .submodule_summaries
4270 .iter()
4271 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4272 .collect(),
4273 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4274 scan_config_url: format!("/runs/scan-config/{run_id}"),
4275 lang_chart_json: {
4276 let entries: Vec<String> = run
4277 .totals_by_language
4278 .iter()
4279 .take(12)
4280 .map(|l| {
4281 let name = l
4282 .language
4283 .display_name()
4284 .replace('\\', "\\\\")
4285 .replace('"', "\\\"");
4286 format!(
4287 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4288 name,
4289 l.code_lines,
4290 l.comment_lines,
4291 l.blank_lines,
4292 l.functions,
4293 l.classes,
4294 l.variables,
4295 l.imports,
4296 l.files,
4297 )
4298 })
4299 .collect();
4300 format!("[{}]", entries.join(","))
4301 },
4302 scatter_chart_json: {
4303 let entries: Vec<String> = run
4304 .totals_by_language
4305 .iter()
4306 .map(|l| {
4307 let name = l
4308 .language
4309 .display_name()
4310 .replace('\\', "\\\\")
4311 .replace('"', "\\\"");
4312 format!(
4313 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4314 name, l.files, l.code_lines, l.total_physical_lines,
4315 )
4316 })
4317 .collect();
4318 format!("[{}]", entries.join(","))
4319 },
4320 semantic_chart_json: {
4321 let entries: Vec<String> = run
4322 .totals_by_language
4323 .iter()
4324 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4325 .map(|l| {
4326 let name = l
4327 .language
4328 .display_name()
4329 .replace('\\', "\\\\")
4330 .replace('"', "\\\"");
4331 format!(
4332 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4333 name, l.functions, l.classes, l.variables, l.imports,
4334 )
4335 })
4336 .collect();
4337 format!("[{}]", entries.join(","))
4338 },
4339 submodule_chart_json: {
4340 let entries: Vec<String> = run
4341 .submodule_summaries
4342 .iter()
4343 .map(|s| {
4344 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4345 format!(
4346 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4347 name,
4348 s.code_lines,
4349 s.comment_lines,
4350 s.blank_lines,
4351 s.total_physical_lines,
4352 s.files_analyzed,
4353 )
4354 })
4355 .collect();
4356 format!("[{}]", entries.join(","))
4357 },
4358 has_submodule_data: !run.submodule_summaries.is_empty(),
4359 has_semantic_data: run
4360 .totals_by_language
4361 .iter()
4362 .any(|l| l.functions > 0 || l.classes > 0),
4363 csp_nonce: csp_nonce.to_owned(),
4364 confluence_configured,
4365 server_mode,
4366 report_header_footer: run
4367 .effective_configuration
4368 .reporting
4369 .report_header_footer
4370 .clone(),
4371 };
4372
4373 Html(
4374 template
4375 .render()
4376 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4377 )
4378 .into_response()
4379}
4380
4381fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4382 let slug: String = report_title
4383 .chars()
4384 .map(|c| {
4385 if c.is_alphanumeric() || c == '-' {
4386 c.to_ascii_lowercase()
4387 } else {
4388 '_'
4389 }
4390 })
4391 .collect::<String>()
4392 .split('_')
4393 .filter(|s| !s.is_empty())
4394 .collect::<Vec<_>>()
4395 .join("_");
4396
4397 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4398
4399 if slug.is_empty() {
4400 format!("report_{short_id}.pdf")
4401 } else {
4402 format!("{slug}_{short_id}.pdf")
4403 }
4404}
4405
4406#[derive(Serialize)]
4407struct PdfStatusResponse {
4408 ready: bool,
4409}
4410
4411async fn pdf_status_handler(
4414 State(state): State<AppState>,
4415 AxumPath(run_id): AxumPath<String>,
4416) -> Response {
4417 let pdf_path = {
4418 let registry = state.artifacts.lock().await;
4419 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4420 };
4421 let pdf_path = if pdf_path.is_some() {
4422 pdf_path
4423 } else {
4424 let reg = state.registry.lock().await;
4425 reg.find_by_run_id(&run_id)
4426 .map(recover_artifacts_from_registry)
4427 .and_then(|a| a.pdf_path)
4428 };
4429 let ready = pdf_path.is_some_and(|p| p.exists());
4430 Json(PdfStatusResponse { ready }).into_response()
4431}
4432
4433async fn download_bundle_handler(
4439 State(state): State<AppState>,
4440 AxumPath(run_id): AxumPath<String>,
4441) -> Response {
4442 let output_dir = {
4444 let cache = state.artifacts.lock().await;
4445 cache.get(&run_id).map(|a| a.output_dir.clone())
4446 };
4447 let output_dir = if let Some(d) = output_dir {
4448 d
4449 } else {
4450 let reg = state.registry.lock().await;
4451 match reg.find_by_run_id(&run_id) {
4452 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4453 None => {
4454 return (
4455 StatusCode::NOT_FOUND,
4456 Json(serde_json::json!({"error": "Run not found"})),
4457 )
4458 .into_response();
4459 }
4460 }
4461 };
4462
4463 if !output_dir.exists() {
4464 return (
4465 StatusCode::NOT_FOUND,
4466 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4467 )
4468 .into_response();
4469 }
4470
4471 let run_id_clone = run_id.clone();
4473 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4474 use flate2::{write::GzEncoder, Compression};
4475 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4476 {
4477 let mut tar = tar::Builder::new(&mut enc);
4478 tar.follow_symlinks(false);
4479 if let Ok(entries) = std::fs::read_dir(&output_dir) {
4482 for entry in entries.filter_map(Result::ok) {
4483 let p = entry.path();
4484 if p.is_file() {
4485 let name = p.file_name().unwrap_or_default().to_string_lossy();
4486 let archive_path = format!("{run_id_clone}/{name}");
4487 tar.append_path_with_name(&p, &archive_path)?;
4488 }
4489 }
4490 }
4491 tar.finish()?;
4492 }
4493 Ok(enc.finish()?)
4494 })
4495 .await;
4496
4497 match archive_result {
4498 Ok(Ok(bytes)) => {
4499 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4500 axum::response::Response::builder()
4501 .status(StatusCode::OK)
4502 .header("Content-Type", "application/gzip")
4503 .header(
4504 "Content-Disposition",
4505 format!("attachment; filename=\"{filename}\""),
4506 )
4507 .header("Content-Length", bytes.len().to_string())
4508 .body(axum::body::Body::from(bytes))
4509 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4510 }
4511 Ok(Err(e)) => (
4512 StatusCode::INTERNAL_SERVER_ERROR,
4513 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4514 )
4515 .into_response(),
4516 Err(e) => (
4517 StatusCode::INTERNAL_SERVER_ERROR,
4518 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4519 )
4520 .into_response(),
4521 }
4522}
4523
4524async fn delete_run_handler(
4529 State(state): State<AppState>,
4530 AxumPath(run_id): AxumPath<String>,
4531) -> Response {
4532 let output_dir = {
4534 let mut cache = state.artifacts.lock().await;
4535 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4536 cache.remove(&run_id);
4537 dir
4538 };
4539 let output_dir = if let Some(d) = output_dir {
4540 d
4541 } else {
4542 let reg = state.registry.lock().await;
4543 reg.find_by_run_id(&run_id)
4544 .map(|e| recover_artifacts_from_registry(e).output_dir)
4545 .unwrap_or_default()
4546 };
4547
4548 {
4550 let mut reg = state.registry.lock().await;
4551 reg.entries.retain(|e| e.run_id != run_id);
4552 let _ = reg.save(&state.registry_path);
4553 }
4554
4555 if output_dir.exists() {
4557 if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4558 return (
4559 StatusCode::INTERNAL_SERVER_ERROR,
4560 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4561 )
4562 .into_response();
4563 }
4564 }
4565
4566 StatusCode::NO_CONTENT.into_response()
4567}
4568
4569async fn cleanup_runs_handler(
4574 State(state): State<AppState>,
4575 Json(body): Json<serde_json::Value>,
4576) -> Response {
4577 let days = body
4578 .get("older_than_days")
4579 .and_then(serde_json::Value::as_u64)
4580 .unwrap_or(30)
4581 .max(1);
4582
4583 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4584
4585 let expired: Vec<(String, PathBuf)> = {
4587 let reg = state.registry.lock().await;
4588 reg.entries
4589 .iter()
4590 .filter(|e| e.timestamp_utc < cutoff)
4591 .map(|e| {
4592 let arts = recover_artifacts_from_registry(e);
4593 (e.run_id.clone(), arts.output_dir)
4594 })
4595 .collect()
4596 };
4597
4598 let mut deleted = 0usize;
4599 for (run_id, output_dir) in &expired {
4600 state.artifacts.lock().await.remove(run_id);
4602 if output_dir.exists() {
4604 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4605 eprintln!(
4606 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4607 output_dir.display()
4608 );
4609 continue;
4610 }
4611 }
4612 deleted += 1;
4613 }
4614
4615 let expired_ids: std::collections::HashSet<&str> =
4617 expired.iter().map(|(id, _)| id.as_str()).collect();
4618 {
4619 let mut reg = state.registry.lock().await;
4620 reg.entries
4621 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4622 let _ = reg.save(&state.registry_path);
4623 }
4624
4625 Json(serde_json::json!({ "deleted": deleted })).into_response()
4626}
4627
4628fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4633 let Some(start) = html.find("nonce=\"") else {
4635 return html
4639 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4640 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4641 };
4642 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
4644 return html.to_owned();
4645 };
4646 let old_nonce = &html[value_start..value_start + end_offset];
4647 html.replace(
4648 &format!("nonce=\"{old_nonce}\""),
4649 &format!("nonce=\"{new_nonce}\""),
4650 )
4651}
4652
4653fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4654 match fs::read_to_string(path) {
4655 Ok(raw) => {
4656 let content = patch_html_nonce(&raw, csp_nonce);
4658 if wants_download {
4659 (
4660 [
4661 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4662 (
4663 header::CONTENT_DISPOSITION,
4664 "attachment; filename=report.html",
4665 ),
4666 ],
4667 content,
4668 )
4669 .into_response()
4670 } else {
4671 Html(content).into_response()
4672 }
4673 }
4674 Err(err) => {
4675 let filename = path.file_name().map_or_else(
4676 || "report.html".to_string(),
4677 |n| n.to_string_lossy().into_owned(),
4678 );
4679 let msg = format!(
4680 "HTML report '{filename}' could not be read.\n\n\
4681 Error: {err}\n\n\
4682 If you moved or renamed the output folder, the stored path is now stale. \
4683 Use 'Open HTML folder' from the results page to browse the output directory."
4684 );
4685 let html = ErrorTemplate {
4686 message: msg,
4687 last_report_url: Some("/view-reports".to_string()),
4688 last_report_label: Some("View Reports".to_string()),
4689 csp_nonce: csp_nonce.to_owned(),
4690 version: env!("CARGO_PKG_VERSION"),
4691 }
4692 .render()
4693 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4694 (StatusCode::NOT_FOUND, Html(html)).into_response()
4695 }
4696 }
4697}
4698
4699fn serve_pdf_artifact(
4701 path: &Path,
4702 report_title: &str,
4703 run_id: &str,
4704 wants_download: bool,
4705 csp_nonce: &str,
4706) -> Response {
4707 match fs::read(path) {
4708 Ok(bytes) => {
4709 let filename = build_pdf_filename(report_title, run_id);
4710 let disposition = if wants_download {
4711 format!("attachment; filename=\"{filename}\"")
4712 } else {
4713 format!("inline; filename=\"{filename}\"")
4714 };
4715 (
4716 [
4717 (header::CONTENT_TYPE, "application/pdf".to_string()),
4718 (header::CONTENT_DISPOSITION, disposition),
4719 ],
4720 bytes,
4721 )
4722 .into_response()
4723 }
4724 Err(err) => {
4725 let filename = path.file_name().map_or_else(
4726 || "report.pdf".to_string(),
4727 |n| n.to_string_lossy().into_owned(),
4728 );
4729 let msg = format!(
4730 "PDF report '{filename}' could not be read.\n\n\
4731 Error: {err}\n\n\
4732 If you moved or renamed the output folder, the stored path is now stale. \
4733 Use 'Open PDF folder' from the results page to browse the output directory."
4734 );
4735 let html = ErrorTemplate {
4736 message: msg,
4737 last_report_url: Some("/view-reports".to_string()),
4738 last_report_label: Some("View Reports".to_string()),
4739 csp_nonce: csp_nonce.to_owned(),
4740 version: env!("CARGO_PKG_VERSION"),
4741 }
4742 .render()
4743 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4744 (StatusCode::NOT_FOUND, Html(html)).into_response()
4745 }
4746 }
4747}
4748
4749fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4751 match fs::read(path) {
4752 Ok(bytes) => {
4753 if wants_download {
4754 (
4755 [
4756 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4757 (
4758 header::CONTENT_DISPOSITION,
4759 "attachment; filename=result.json",
4760 ),
4761 ],
4762 bytes,
4763 )
4764 .into_response()
4765 } else {
4766 (
4767 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4768 bytes,
4769 )
4770 .into_response()
4771 }
4772 }
4773 Err(err) => {
4774 let filename = path.file_name().map_or_else(
4775 || "result.json".to_string(),
4776 |n| n.to_string_lossy().into_owned(),
4777 );
4778 let msg = format!(
4779 "JSON result '{filename}' could not be read.\n\n\
4780 Error: {err}\n\n\
4781 If you moved or renamed the output folder, the stored path is now stale. \
4782 Use 'Open JSON folder' from the results page to browse the output directory."
4783 );
4784 let html = ErrorTemplate {
4785 message: msg,
4786 last_report_url: Some("/view-reports".to_string()),
4787 last_report_label: Some("View Reports".to_string()),
4788 csp_nonce: csp_nonce.to_owned(),
4789 version: env!("CARGO_PKG_VERSION"),
4790 }
4791 .render()
4792 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4793 (StatusCode::NOT_FOUND, Html(html)).into_response()
4794 }
4795 }
4796}
4797
4798fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4800 let output_dir = entry
4801 .html_path
4802 .as_ref()
4803 .or(entry.json_path.as_ref())
4804 .or(entry.pdf_path.as_ref())
4805 .or(entry.csv_path.as_ref())
4806 .or(entry.xlsx_path.as_ref())
4807 .and_then(|p| p.parent().map(PathBuf::from))
4808 .unwrap_or_default();
4809 let pdf_path = entry.pdf_path.clone().or_else(|| {
4812 let candidate = output_dir.join("report.pdf");
4813 candidate.exists().then_some(candidate)
4814 });
4815 let csv_path = entry.csv_path.clone().or_else(|| {
4819 fs::read_dir(&output_dir).ok().and_then(|entries| {
4820 entries
4821 .filter_map(std::result::Result::ok)
4822 .find(|e| {
4823 let n = e.file_name();
4824 let n = n.to_string_lossy();
4825 n.starts_with("report_") && n.ends_with(".csv")
4826 })
4827 .map(|e| e.path())
4828 })
4829 });
4830 let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4831 fs::read_dir(&output_dir).ok().and_then(|entries| {
4832 entries
4833 .filter_map(std::result::Result::ok)
4834 .find(|e| {
4835 let n = e.file_name();
4836 let n = n.to_string_lossy();
4837 n.starts_with("report_") && n.ends_with(".xlsx")
4838 })
4839 .map(|e| e.path())
4840 })
4841 });
4842 RunArtifacts {
4843 output_dir: output_dir.clone(),
4844 html_path: entry.html_path.clone(),
4845 pdf_path,
4846 json_path: entry.json_path.clone(),
4847 csv_path,
4848 xlsx_path,
4849 scan_config_path: find_scan_config_in_dir(&output_dir),
4850 report_title: entry.project_label.clone(),
4851 result_context: RunResultContext::default(),
4852 }
4853}
4854
4855#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
4857 state: &AppState,
4858 run_id: &str,
4859 csp_nonce: &str,
4860) -> Result<RunArtifacts, Response> {
4861 let cached = state.artifacts.lock().await.get(run_id).cloned();
4862 if let Some(a) = cached {
4863 return Ok(a);
4864 }
4865 let reg = state.registry.lock().await;
4866 if let Some(entry) = reg.find_by_run_id(run_id) {
4867 return Ok(recover_artifacts_from_registry(entry));
4868 }
4869 drop(reg);
4870 let short_id = &run_id[..run_id.len().min(8)];
4871 let hint = if matches!(
4872 run_id,
4873 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4874 ) {
4875 format!(
4876 " The URL format appears to be reversed — \
4877 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4878 Use the View Reports page to navigate to your scan."
4879 )
4880 } else {
4881 " The report may have been deleted or the report directory moved. \
4882 Use View Reports to browse your scan history."
4883 .to_string()
4884 };
4885 let error_html = ErrorTemplate {
4886 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
4887 last_report_url: Some("/view-reports".to_string()),
4888 last_report_label: Some("View Reports".to_string()),
4889 csp_nonce: csp_nonce.to_owned(),
4890 version: env!("CARGO_PKG_VERSION"),
4891 }
4892 .render()
4893 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4894 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
4895}
4896
4897#[allow(clippy::too_many_lines)] async fn artifact_handler(
4899 State(state): State<AppState>,
4900 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4901 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4902 Query(query): Query<ArtifactQuery>,
4903) -> Response {
4904 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
4905 Ok(a) => a,
4906 Err(r) => return r,
4907 };
4908
4909 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4910
4911 match artifact.as_str() {
4912 "html" => {
4913 let Some(path) = artifact_set.html_path else {
4914 return StatusCode::NOT_FOUND.into_response();
4915 };
4916 serve_html_artifact(&path, wants_download, &csp_nonce)
4917 }
4918 "pdf" => {
4919 let Some(path) = artifact_set.pdf_path else {
4920 let msg = "PDF report was not generated for this run, or was not recorded in \
4921 the scan registry. Re-run the analysis with PDF output enabled."
4922 .to_string();
4923 let html = ErrorTemplate {
4924 message: msg,
4925 last_report_url: Some(format!("/runs/html/{run_id}")),
4926 last_report_label: Some("View HTML Report".to_string()),
4927 csp_nonce: csp_nonce.clone(),
4928 version: env!("CARGO_PKG_VERSION"),
4929 }
4930 .render()
4931 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4932 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4933 };
4934 if !path.exists() {
4937 let html = format!(
4938 "<!doctype html><html lang=\"en\"><head>\
4939 <meta charset=utf-8>\
4940 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4941 <meta http-equiv=\"refresh\" content=\"5\">\
4942 <title>OxideSLOC | Generating PDF\u{2026}</title>\
4943 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4944 <style nonce=\"{csp_nonce}\">\
4945 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4946 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4947 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4948 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4949 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4950 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4951 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4952 background:var(--bg);color:var(--text);}}\
4953 .top-nav{{position:sticky;top:0;z-index:30;\
4954 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4955 border-bottom:1px solid rgba(255,255,255,0.12);\
4956 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4957 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4958 min-height:56px;display:flex;align-items:center;gap:14px;}}\
4959 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4960 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4961 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4962 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4963 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4964 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4965 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4966 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4967 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4968 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4969 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4970 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4971 justify-content:center;min-height:38px;border-radius:999px;\
4972 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4973 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4974 .theme-toggle .icon-sun{{display:none;}}\
4975 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4976 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4977 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
4978 display:flex;align-items:center;justify-content:center;\
4979 min-height:calc(100vh - 56px);}}\
4980 .panel{{background:var(--surface);border:1px solid var(--line);\
4981 border-radius:var(--radius);box-shadow:var(--shadow);\
4982 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4983 .spin-ring{{width:56px;height:56px;border-radius:50%;\
4984 border:5px solid var(--line);border-top-color:var(--oxide-2);\
4985 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4986 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4987 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4988 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4989 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4990 min-height:42px;padding:0 20px;border-radius:14px;\
4991 border:1px solid var(--line-strong);text-decoration:none;\
4992 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4993 .back-link:hover{{background:var(--line);}}\
4994 </style></head>\
4995 <body>\
4996 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4997 <a class=\"brand\" href=\"/\">\
4998 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4999 <div class=\"brand-copy\">\
5000 <div class=\"brand-title\">OxideSLOC</div>\
5001 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5002 </div>\
5003 </a>\
5004 <div class=\"nav-right\">\
5005 <a class=\"nav-pill\" href=\"/\">Home</a>\
5006 <div class=\"nav-dropdown\">\
5007 <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>\
5008 <div class=\"nav-dropdown-menu\">\
5009 <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>\
5010 </div>\
5011 </div>\
5012 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5013 <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>\
5014 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5015 <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>\
5016 </button>\
5017 </div>\
5018 </div></div>\
5019 <div class=\"page\"><div class=\"panel\">\
5020 <div class=\"spin-ring\"></div>\
5021 <h1>Generating PDF\u{2026}</h1>\
5022 <p>The PDF is being rendered from the HTML report.<br>\
5023 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
5024 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5025 </div></div>\
5026 <script nonce=\"{csp_nonce}\">\
5027 (function(){{\
5028 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5029 if(s===\"dark\")b.classList.add(\"dark-theme\");\
5030 var t=document.getElementById(\"theme-toggle\");\
5031 if(t)t.addEventListener(\"click\",function(){{\
5032 var d=b.classList.toggle(\"dark-theme\");\
5033 localStorage.setItem(k,d?\"dark\":\"light\");\
5034 }});\
5035 }})();\
5036 </script>\
5037 </body></html>"
5038 );
5039 return Html(html).into_response();
5040 }
5041 serve_pdf_artifact(
5042 &path,
5043 &artifact_set.report_title,
5044 &run_id,
5045 wants_download,
5046 &csp_nonce,
5047 )
5048 }
5049 "json" => {
5050 let Some(path) = artifact_set.json_path else {
5051 let msg = "JSON result was not generated for this run, or was not recorded in \
5052 the scan registry. Re-run the analysis with JSON output enabled."
5053 .to_string();
5054 let html = ErrorTemplate {
5055 message: msg,
5056 last_report_url: Some("/view-reports".to_string()),
5057 last_report_label: Some("View Reports".to_string()),
5058 csp_nonce: csp_nonce.clone(),
5059 version: env!("CARGO_PKG_VERSION"),
5060 }
5061 .render()
5062 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5063 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5064 };
5065 serve_json_artifact(&path, wants_download, &csp_nonce)
5066 }
5067 "csv" => {
5068 let Some(path) = artifact_set.csv_path else {
5069 let msg = "CSV report was not generated for this run, or was not recorded in \
5070 the scan registry."
5071 .to_string();
5072 let html = ErrorTemplate {
5073 message: msg,
5074 last_report_url: Some(format!("/runs/html/{run_id}")),
5075 last_report_label: Some("View HTML Report".to_string()),
5076 csp_nonce: csp_nonce.clone(),
5077 version: env!("CARGO_PKG_VERSION"),
5078 }
5079 .render()
5080 .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5081 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5082 };
5083 fs::read(&path).map_or_else(
5084 |_| StatusCode::NOT_FOUND.into_response(),
5085 |bytes| {
5086 let filename = path.file_name().map_or_else(
5087 || "report.csv".to_string(),
5088 |n| n.to_string_lossy().into_owned(),
5089 );
5090 (
5091 [
5092 (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5093 (
5094 header::CONTENT_DISPOSITION,
5095 format!("attachment; filename=\"{filename}\""),
5096 ),
5097 ],
5098 bytes,
5099 )
5100 .into_response()
5101 },
5102 )
5103 }
5104 "xlsx" => {
5105 let Some(path) = artifact_set.xlsx_path else {
5106 let msg = "Excel report was not generated for this run, or was not recorded in \
5107 the scan registry."
5108 .to_string();
5109 let html = ErrorTemplate {
5110 message: msg,
5111 last_report_url: Some(format!("/runs/html/{run_id}")),
5112 last_report_label: Some("View HTML Report".to_string()),
5113 csp_nonce: csp_nonce.clone(),
5114 version: env!("CARGO_PKG_VERSION"),
5115 }
5116 .render()
5117 .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5118 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5119 };
5120 fs::read(&path).map_or_else(
5121 |_| StatusCode::NOT_FOUND.into_response(),
5122 |bytes| {
5123 let filename = path.file_name().map_or_else(
5124 || "report.xlsx".to_string(),
5125 |n| n.to_string_lossy().into_owned(),
5126 );
5127 (
5128 [
5129 (
5130 header::CONTENT_TYPE,
5131 "application/vnd.openxmlformats-officedocument\
5132 .spreadsheetml.sheet"
5133 .to_string(),
5134 ),
5135 (
5136 header::CONTENT_DISPOSITION,
5137 format!("attachment; filename=\"{filename}\""),
5138 ),
5139 ],
5140 bytes,
5141 )
5142 .into_response()
5143 },
5144 )
5145 }
5146 "scan-config" => {
5147 let path = artifact_set
5148 .scan_config_path
5149 .as_deref()
5150 .map(std::path::Path::to_path_buf)
5151 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5152 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5153 fs::read(&path).map_or_else(
5154 |_| StatusCode::NOT_FOUND.into_response(),
5155 |bytes| {
5156 (
5157 [
5158 (
5159 header::CONTENT_TYPE,
5160 "application/json; charset=utf-8".to_string(),
5161 ),
5162 (
5163 header::CONTENT_DISPOSITION,
5164 "attachment; filename=\"scan-config.json\"".to_string(),
5165 ),
5166 ],
5167 bytes,
5168 )
5169 .into_response()
5170 },
5171 )
5172 }
5173 _ if artifact.starts_with("sub_") => {
5174 if artifact.len() > 128
5175 || !artifact
5176 .chars()
5177 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5178 {
5179 return StatusCode::BAD_REQUEST.into_response();
5180 }
5181 let filename = format!("{artifact}.html");
5182 let path = artifact_set.output_dir.join(&filename);
5183 if !path.exists() {
5184 let html = ErrorTemplate {
5185 message: format!(
5186 "Sub-report '{artifact}' was not found in the run directory.\n\
5187 Re-run the analysis with 'Detect and separate git submodules' \
5188 and HTML output enabled."
5189 ),
5190 last_report_url: Some("/view-reports".to_string()),
5191 last_report_label: Some("View Reports".to_string()),
5192 csp_nonce: csp_nonce.clone(),
5193 version: env!("CARGO_PKG_VERSION"),
5194 }
5195 .render()
5196 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5197 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5198 }
5199 serve_html_artifact(&path, wants_download, &csp_nonce)
5200 }
5201 _ => StatusCode::NOT_FOUND.into_response(),
5202 }
5203}
5204
5205struct SubmoduleLinkRow {
5208 name: String,
5209 url: String,
5210}
5211
5212struct HistoryEntryRow {
5213 run_id: String,
5214 run_id_short: String,
5215 timestamp: String,
5216 timestamp_utc_ms: i64,
5217 project_label: String,
5218 project_path: String,
5219 files_analyzed: u64,
5220 files_skipped: u64,
5221 code_lines: u64,
5222 comment_lines: u64,
5223 blank_lines: u64,
5224 git_branch: String,
5225 git_commit: String,
5226 has_html: bool,
5227 has_json: bool,
5228 has_pdf: bool,
5229 submodule_links: Vec<SubmoduleLinkRow>,
5230 submodule_names_csv: String,
5232}
5233
5234fn nth_weekday_of_month(
5236 year: i32,
5237 month: u32,
5238 weekday: chrono::Weekday,
5239 n: u32,
5240) -> chrono::NaiveDate {
5241 use chrono::Datelike;
5242 let mut count = 0u32;
5243 let mut day = 1u32;
5244 loop {
5245 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5246 if d.weekday() == weekday {
5247 count += 1;
5248 if count == n {
5249 return d;
5250 }
5251 }
5252 day += 1;
5253 }
5254}
5255
5256fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5260 use chrono::{Datelike, TimeZone};
5261 let year = dt.year();
5262 let dst_start = chrono::Utc.from_utc_datetime(
5263 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5264 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5265 );
5266 let dst_end = chrono::Utc.from_utc_datetime(
5267 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5268 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5269 );
5270 dt >= dst_start && dt < dst_end
5271}
5272
5273fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5274 if is_pacific_dst(dt) {
5275 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5276 .format("%Y-%m-%d %H:%M PDT")
5277 .to_string()
5278 } else {
5279 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5280 .format("%Y-%m-%d %H:%M PST")
5281 .to_string()
5282 }
5283}
5284
5285fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>, parens: bool) -> String {
5289 let (offset, tz) = if is_pacific_dst(dt) {
5290 (
5291 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
5292 "PDT",
5293 )
5294 } else {
5295 (
5296 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
5297 "PST",
5298 )
5299 };
5300 let t = dt
5301 .with_timezone(&offset)
5302 .format("%Y-%m-%d %H:%M:%S")
5303 .to_string();
5304 if parens {
5305 format!("{t} ({tz})")
5306 } else {
5307 format!("{t} {tz}")
5308 }
5309}
5310
5311fn fmt_git_date(iso: &str) -> Option<String> {
5312 chrono::DateTime::parse_from_rfc3339(iso)
5313 .ok()
5314 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5315}
5316
5317fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5318 reg.entries
5319 .iter()
5320 .map(|e| {
5321 let submodule_links = {
5322 let mut links: Vec<SubmoduleLinkRow> = vec![];
5323 let sub_dir = e
5324 .html_path
5325 .as_ref()
5326 .and_then(|p| p.parent())
5327 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5328 if let Some(dir) = sub_dir {
5329 if let Ok(rd) = std::fs::read_dir(dir) {
5330 for entry_res in rd.flatten() {
5331 let fname = entry_res.file_name();
5332 let fname_str = fname.to_string_lossy();
5333 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5334 let stem = &fname_str[..fname_str.len() - 5];
5335 let display = stem[4..].replace('-', " ");
5336 links.push(SubmoduleLinkRow {
5337 name: display,
5338 url: format!("/runs/{stem}/{}", e.run_id),
5339 });
5340 }
5341 }
5342 }
5343 }
5344 links.sort_by(|a, b| a.name.cmp(&b.name));
5345 links
5346 };
5347 let submodule_names_csv = submodule_links
5348 .iter()
5349 .map(|l| l.name.as_str())
5350 .collect::<Vec<_>>()
5351 .join(",");
5352 HistoryEntryRow {
5353 run_id: e.run_id.clone(),
5354 run_id_short: e
5355 .run_id
5356 .split('-')
5357 .next_back()
5358 .unwrap_or(&e.run_id)
5359 .chars()
5360 .take(7)
5361 .collect(),
5362 timestamp: fmt_la_time(e.timestamp_utc),
5363 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5364 project_label: e.project_label.clone(),
5365 project_path: e
5366 .input_roots
5367 .first()
5368 .map(|s| sanitize_path_str(s))
5369 .unwrap_or_default(),
5370 files_analyzed: e.summary.files_analyzed,
5371 files_skipped: e.summary.files_skipped,
5372 code_lines: e.summary.code_lines,
5373 comment_lines: e.summary.comment_lines,
5374 blank_lines: e.summary.blank_lines,
5375 git_branch: e.git_branch.clone().unwrap_or_default(),
5376 git_commit: e.git_commit.clone().unwrap_or_default(),
5377 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5378 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5379 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5380 submodule_links,
5381 submodule_names_csv,
5382 }
5383 })
5384 .collect()
5385}
5386
5387#[derive(Deserialize, Default)]
5388struct HistoryQuery {
5389 linked: Option<String>,
5390 error: Option<String>,
5391}
5392
5393async fn history_handler(
5394 State(state): State<AppState>,
5395 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5396 Query(query): Query<HistoryQuery>,
5397) -> impl IntoResponse {
5398 auto_scan_watched_dirs(&state).await;
5400 let watched_dirs: Vec<String> = {
5401 let wd = state.watched_dirs.lock().await;
5402 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5403 };
5404 let mut entries = {
5405 let reg = state.registry.lock().await;
5406 make_history_rows(®)
5407 };
5408 entries.retain(|e| e.has_html);
5409 let total_scans = entries.len();
5410 let linked_count = query
5411 .linked
5412 .as_deref()
5413 .and_then(|s| s.parse::<usize>().ok())
5414 .unwrap_or(0);
5415 let browse_error = query.error.filter(|s| !s.is_empty());
5416 let template = HistoryTemplate {
5417 version: env!("CARGO_PKG_VERSION"),
5418 entries,
5419 total_scans,
5420 linked_count,
5421 browse_error,
5422 watched_dirs,
5423 csp_nonce,
5424 server_mode: state.server_mode,
5425 };
5426 Html(
5427 template
5428 .render()
5429 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5430 )
5431 .into_response()
5432}
5433
5434async fn compare_select_handler(
5435 State(state): State<AppState>,
5436 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5437) -> impl IntoResponse {
5438 auto_scan_watched_dirs(&state).await;
5439 let watched_dirs: Vec<String> = {
5440 let wd = state.watched_dirs.lock().await;
5441 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5442 };
5443 let mut entries = {
5444 let reg = state.registry.lock().await;
5445 make_history_rows(®)
5446 };
5447 entries.retain(|e| e.has_json);
5448 let total_scans = entries.len();
5449 let template = CompareSelectTemplate {
5450 version: env!("CARGO_PKG_VERSION"),
5451 entries,
5452 total_scans,
5453 watched_dirs,
5454 csp_nonce,
5455 server_mode: state.server_mode,
5456 };
5457 Html(
5458 template
5459 .render()
5460 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5461 )
5462 .into_response()
5463}
5464
5465#[derive(Deserialize, Default)]
5468struct CompareQuery {
5469 a: Option<String>,
5470 b: Option<String>,
5471 sub: Option<String>,
5473 scope: Option<String>,
5475}
5476
5477struct CompareFileDeltaRow {
5478 relative_path: String,
5479 language: String,
5480 status: String,
5481 baseline_code: i64,
5482 current_code: i64,
5483 code_delta_str: String,
5484 code_delta_class: String,
5485 comment_delta_str: String,
5486 comment_delta_class: String,
5487 total_delta_str: String,
5488 total_delta_class: String,
5489}
5490
5491fn recompute_summary_from_records(run: &mut AnalysisRun) {
5494 let files_analyzed = run
5495 .per_file_records
5496 .iter()
5497 .filter(|r| r.language.is_some())
5498 .count() as u64;
5499 let code_lines: u64 = run
5500 .per_file_records
5501 .iter()
5502 .map(|r| r.effective_counts.code_lines)
5503 .sum();
5504 let comment_lines: u64 = run
5505 .per_file_records
5506 .iter()
5507 .map(|r| r.effective_counts.comment_lines)
5508 .sum();
5509 let blank_lines: u64 = run
5510 .per_file_records
5511 .iter()
5512 .map(|r| r.effective_counts.blank_lines)
5513 .sum();
5514 run.summary_totals.files_analyzed = files_analyzed;
5515 run.summary_totals.files_considered = files_analyzed;
5516 run.summary_totals.code_lines = code_lines;
5517 run.summary_totals.comment_lines = comment_lines;
5518 run.summary_totals.blank_lines = blank_lines;
5519 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5520}
5521
5522fn fmt_delta(n: i64) -> String {
5523 if n > 0 {
5524 format!("+{n}")
5525 } else {
5526 format!("{n}")
5527 }
5528}
5529
5530fn delta_class(n: i64) -> &'static str {
5531 use std::cmp::Ordering;
5532 match n.cmp(&0) {
5533 Ordering::Greater => "pos",
5534 Ordering::Less => "neg",
5535 Ordering::Equal => "zero",
5536 }
5537}
5538
5539#[allow(clippy::cast_precision_loss)]
5541fn fmt_pct(delta: i64, baseline: u64) -> String {
5542 if baseline == 0 {
5543 return "—".to_string();
5544 }
5545 #[allow(clippy::cast_precision_loss)]
5546 let pct = (delta as f64 / baseline as f64) * 100.0;
5547 if pct > 0.049 {
5548 format!("+{pct:.1}%")
5549 } else if pct < -0.049 {
5550 format!("{pct:.1}%")
5551 } else {
5552 "±0%".to_string()
5553 }
5554}
5555
5556fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5558 prev.map_or_else(
5559 || ("—".to_string(), "na"),
5560 |p| {
5561 #[allow(clippy::cast_possible_wrap)]
5562 let d = curr as i64 - p as i64;
5563 (fmt_delta(d), delta_class(d))
5564 },
5565 )
5566}
5567
5568#[allow(clippy::result_large_err)] fn load_scan_for_compare(
5570 json_path: &std::path::Path,
5571 scan_label: &str,
5572 run_id: &str,
5573 server_mode: bool,
5574 compare_url: &str,
5575 csp_nonce: &str,
5576) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5577 match read_json(json_path) {
5578 Ok(r) => Ok(r),
5579 Err(e) => {
5580 if server_mode {
5581 let html = ErrorTemplate {
5582 message: format!(
5583 "Could not load {scan_label} scan data. The scan output folder may have \
5584 been moved, renamed, or deleted. Re-running the analysis will create \
5585 fresh comparison data."
5586 ),
5587 last_report_url: Some("/compare-scans".to_string()),
5588 last_report_label: Some("Compare Scans".to_string()),
5589 csp_nonce: csp_nonce.to_owned(),
5590 version: env!("CARGO_PKG_VERSION"),
5591 }
5592 .render()
5593 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5594 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5595 }
5596 let msg = format!(
5597 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5598 json_path.display()
5599 );
5600 let folder_hint = json_path
5601 .parent()
5602 .map(|p| p.display().to_string())
5603 .unwrap_or_default();
5604 Err(missing_scan_relocate_response(
5605 &msg,
5606 run_id,
5607 &folder_hint,
5608 compare_url,
5609 false,
5610 csp_nonce,
5611 ))
5612 }
5613 }
5614}
5615
5616struct ChurnStats {
5617 new_scope: bool,
5618 scope_flag: bool,
5619 churn_rate_str: String,
5620 churn_rate_class: String,
5621}
5622
5623fn compute_churn_stats(
5624 baseline_code: u64,
5625 current_code: u64,
5626 lines_added: i64,
5627 lines_removed: i64,
5628) -> ChurnStats {
5629 let new_scope = baseline_code == 0 && current_code > 0;
5630 #[allow(clippy::cast_precision_loss)]
5631 let churn_pct = if baseline_code > 0 {
5632 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5633 } else {
5634 0.0
5635 };
5636 #[allow(clippy::cast_precision_loss)]
5637 let scope_flag =
5638 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5639 let churn_rate_str = if new_scope {
5640 "New".to_string()
5641 } else if baseline_code > 0 {
5642 format!("{churn_pct:.1}%")
5643 } else {
5644 "—".to_string()
5645 };
5646 let churn_rate_class = if new_scope || churn_pct > 20.0 {
5647 "high".to_string()
5648 } else if churn_pct > 5.0 {
5649 "med".to_string()
5650 } else {
5651 "low".to_string()
5652 };
5653 ChurnStats {
5654 new_scope,
5655 scope_flag,
5656 churn_rate_str,
5657 churn_rate_class,
5658 }
5659}
5660
5661fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5665 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5666 if !has_data {
5667 return String::new();
5668 }
5669 let base_str = s
5670 .baseline_coverage_line_pct
5671 .map(|p| format!("{p:.1}%"))
5672 .unwrap_or_else(|| "\u{2014}".into());
5673 let curr_str = s
5674 .current_coverage_line_pct
5675 .map(|p| format!("{p:.1}%"))
5676 .unwrap_or_else(|| "\u{2014}".into());
5677 let (delta_str, cls) = match s.coverage_line_pct_delta {
5678 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5679 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5680 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5681 None => ("\u{2014}".into(), "zero"),
5682 };
5683 format!(
5684 r#"<div class="delta-card">
5685 <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>
5686 <div class="delta-card-label">Line coverage</div>
5687 <div class="delta-card-from">Before: {base_str}</div>
5688 <div class="delta-card-to">{curr_str}</div>
5689 <span class="delta-card-change {cls}">{delta_str}</span>
5690 </div>"#
5691 )
5692}
5693
5694#[allow(clippy::too_many_lines)]
5695async fn compare_handler(
5696 State(state): State<AppState>,
5697 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5698 Query(query): Query<CompareQuery>,
5699) -> impl IntoResponse {
5700 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5703 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5704 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5705 };
5706
5707 let (maybe_a, maybe_b) = {
5708 let reg = state.registry.lock().await;
5709 (
5710 reg.find_by_run_id(&run_id_a).cloned(),
5711 reg.find_by_run_id(&run_id_b).cloned(),
5712 )
5713 };
5714
5715 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5716 let html = ErrorTemplate {
5717 message: "One or both run IDs were not found in scan history. \
5718 The runs may have been deleted or the registry may have been reset."
5719 .to_string(),
5720 last_report_url: Some("/compare-scans".to_string()),
5721 last_report_label: Some("Compare Scans".to_string()),
5722 csp_nonce: csp_nonce.clone(),
5723 version: env!("CARGO_PKG_VERSION"),
5724 }
5725 .render()
5726 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5727 return Html(html).into_response();
5728 };
5729
5730 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5732 (entry_a, entry_b)
5733 } else {
5734 (entry_b, entry_a)
5735 };
5736
5737 if baseline_entry.run_id != run_id_a {
5741 let canonical = format!(
5742 "/compare?a={}&b={}",
5743 baseline_entry.run_id, current_entry.run_id
5744 );
5745 return axum::response::Redirect::to(&canonical).into_response();
5746 }
5747
5748 let (Some(base_json), Some(curr_json)) = (
5749 baseline_entry.json_path.as_ref(),
5750 current_entry.json_path.as_ref(),
5751 ) else {
5752 let html = ErrorTemplate {
5753 message: "Full comparison requires JSON scan data, which was not saved for one or \
5754 both of these runs. JSON is now always saved for new scans — re-run the \
5755 affected projects to enable comparisons."
5756 .to_string(),
5757 last_report_url: Some("/compare-scans".to_string()),
5758 last_report_label: Some("Compare Scans".to_string()),
5759 csp_nonce: csp_nonce.clone(),
5760 version: env!("CARGO_PKG_VERSION"),
5761 }
5762 .render()
5763 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
5764 return Html(html).into_response();
5765 };
5766
5767 let compare_url = format!(
5768 "/compare?a={}&b={}",
5769 baseline_entry.run_id, current_entry.run_id
5770 );
5771
5772 let baseline_run = match load_scan_for_compare(
5773 base_json,
5774 "baseline",
5775 &baseline_entry.run_id,
5776 state.server_mode,
5777 &compare_url,
5778 &csp_nonce,
5779 ) {
5780 Ok(r) => r,
5781 Err(resp) => return resp,
5782 };
5783 let current_run = match load_scan_for_compare(
5784 curr_json,
5785 "current",
5786 ¤t_entry.run_id,
5787 state.server_mode,
5788 &compare_url,
5789 &csp_nonce,
5790 ) {
5791 Ok(r) => r,
5792 Err(resp) => return resp,
5793 };
5794
5795 let active_submodule = query.sub.clone();
5796 let super_scope_active = query.scope.as_deref() == Some("super");
5797
5798 let submodule_options = baseline_run
5799 .submodule_summaries
5800 .iter()
5801 .chain(current_run.submodule_summaries.iter())
5802 .map(|s| s.name.clone())
5803 .collect::<std::collections::BTreeSet<_>>()
5804 .into_iter()
5805 .collect::<Vec<_>>();
5806 let has_any_submodule_data = !submodule_options.is_empty();
5807
5808 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
5810 let mut b = baseline_run;
5811 let mut c = current_run;
5812 b.per_file_records
5813 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5814 c.per_file_records
5815 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5816 recompute_summary_from_records(&mut b);
5817 recompute_summary_from_records(&mut c);
5818 (b, c)
5819 } else if super_scope_active {
5820 let mut b = baseline_run;
5821 let mut c = current_run;
5822 b.per_file_records.retain(|f| f.submodule.is_none());
5823 c.per_file_records.retain(|f| f.submodule.is_none());
5824 recompute_summary_from_records(&mut b);
5825 recompute_summary_from_records(&mut c);
5826 (b, c)
5827 } else {
5828 (baseline_run, current_run)
5829 };
5830
5831 let comparison = compute_delta(&effective_baseline, &effective_current);
5832
5833 let file_rows: Vec<CompareFileDeltaRow> = comparison
5834 .file_deltas
5835 .iter()
5836 .map(|d| CompareFileDeltaRow {
5837 relative_path: d.relative_path.clone(),
5838 language: d.language.clone().unwrap_or_else(|| "—".into()),
5839 status: match d.status {
5840 FileChangeStatus::Added => "added".into(),
5841 FileChangeStatus::Removed => "removed".into(),
5842 FileChangeStatus::Modified => "modified".into(),
5843 FileChangeStatus::Unchanged => "unchanged".into(),
5844 },
5845 baseline_code: d.baseline_code,
5846 current_code: d.current_code,
5847 code_delta_str: fmt_delta(d.code_delta),
5848 code_delta_class: delta_class(d.code_delta).into(),
5849 comment_delta_str: fmt_delta(d.comment_delta),
5850 comment_delta_class: delta_class(d.comment_delta).into(),
5851 total_delta_str: fmt_delta(d.total_delta),
5852 total_delta_class: delta_class(d.total_delta).into(),
5853 })
5854 .collect();
5855
5856 let project_path = baseline_entry
5857 .input_roots
5858 .first()
5859 .map(|s| sanitize_path_str(s))
5860 .unwrap_or_default();
5861 let lines_added = sum_added_code_lines(&comparison);
5862 let lines_removed = sum_removed_code_lines(&comparison);
5863 let churn = compute_churn_stats(
5864 comparison.summary.baseline_code,
5865 comparison.summary.current_code,
5866 lines_added,
5867 lines_removed,
5868 );
5869 let s = &comparison.summary;
5870 let template = CompareTemplate {
5871 version: env!("CARGO_PKG_VERSION"),
5872 project_label: baseline_entry.project_label.clone(),
5873 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
5874 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
5875 baseline_run_id: baseline_entry.run_id.clone(),
5876 current_run_id: current_entry.run_id.clone(),
5877 baseline_run_id_short: baseline_entry
5878 .run_id
5879 .split('-')
5880 .next_back()
5881 .unwrap_or(&baseline_entry.run_id)
5882 .chars()
5883 .take(7)
5884 .collect(),
5885 current_run_id_short: current_entry
5886 .run_id
5887 .split('-')
5888 .next_back()
5889 .unwrap_or(¤t_entry.run_id)
5890 .chars()
5891 .take(7)
5892 .collect(),
5893 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5894 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5895 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5896 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5897 project_path: project_path.clone(),
5898 baseline_code: s.baseline_code,
5899 current_code: s.current_code,
5900 code_lines_delta_str: fmt_delta(s.code_lines_delta),
5901 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5902 baseline_files: s.baseline_files,
5903 current_files: s.current_files,
5904 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5905 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5906 baseline_comments: s.baseline_comments,
5907 current_comments: s.current_comments,
5908 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5909 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5910 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5911 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5912 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5913 code_lines_added: lines_added,
5914 code_lines_removed: lines_removed,
5915 new_scope: churn.new_scope,
5916 churn_rate_str: churn.churn_rate_str,
5917 churn_rate_class: churn.churn_rate_class,
5918 scope_flag: churn.scope_flag,
5919 files_added: comparison.files_added,
5920 files_removed: comparison.files_removed,
5921 files_modified: comparison.files_modified,
5922 files_unchanged: comparison.files_unchanged,
5923 file_rows,
5924 baseline_git_author: baseline_entry.git_author.clone(),
5925 current_git_author: current_entry.git_author.clone(),
5926 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5927 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5928 baseline_git_tags: baseline_entry.git_tags.clone(),
5929 current_git_tags: current_entry.git_tags.clone(),
5930 baseline_git_commit_date: baseline_entry
5931 .git_commit_date
5932 .as_deref()
5933 .and_then(fmt_git_date),
5934 current_git_commit_date: current_entry
5935 .git_commit_date
5936 .as_deref()
5937 .and_then(fmt_git_date),
5938 project_name: project_path
5939 .rsplit(['/', '\\'])
5940 .find(|s| !s.is_empty())
5941 .unwrap_or(&project_path)
5942 .to_string(),
5943 submodule_options,
5944 has_any_submodule_data,
5945 active_submodule,
5946 super_scope_active,
5947 csp_nonce,
5948 coverage_delta_card: build_coverage_delta_card(s),
5949 };
5950
5951 Html(
5952 template
5953 .render()
5954 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5955 )
5956 .into_response()
5957}
5958
5959fn format_number(n: u64) -> String {
5967 let s = n.to_string();
5968 let mut out = String::with_capacity(s.len() + s.len() / 3);
5969 let len = s.len();
5970 for (i, c) in s.chars().enumerate() {
5971 if i > 0 && (len - i).is_multiple_of(3) {
5972 out.push(',');
5973 }
5974 out.push(c);
5975 }
5976 out
5977}
5978
5979const fn badge_char_width(c: char) -> f64 {
5980 match c {
5981 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5982 'm' | 'w' => 9.0,
5983 ' ' => 4.0,
5984 _ => 6.5,
5985 }
5986}
5987
5988#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5989fn badge_text_px(text: &str) -> u32 {
5990 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5991}
5992
5993fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5994 let lw = badge_text_px(label) + 20;
5995 let rw = badge_text_px(value) + 20;
5996 let total = lw + rw;
5997 let lx = lw / 2;
5998 let rx = lw + rw / 2;
5999 let le = escape_html(label);
6000 let ve = escape_html(value);
6001 let ce = escape_html(color);
6002 format!(
6003 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6004 <rect width="{total}" height="20" fill="#555"/>
6005 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6006 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6007 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6008 <text x="{lx}" y="13">{le}</text>
6009 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6010 <text x="{rx}" y="13">{ve}</text>
6011 </g>
6012</svg>"##
6013 )
6014}
6015
6016#[derive(Deserialize)]
6017struct BadgeQuery {
6018 label: Option<String>,
6019 color: Option<String>,
6020}
6021
6022async fn badge_handler(
6023 State(state): State<AppState>,
6024 AxumPath(metric): AxumPath<String>,
6025 Query(query): Query<BadgeQuery>,
6026) -> Response {
6027 let entry = {
6028 let reg = state.registry.lock().await;
6029 reg.entries.first().cloned()
6030 };
6031
6032 let Some(entry) = entry else {
6033 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6034 return (
6035 [
6036 (header::CONTENT_TYPE, "image/svg+xml"),
6037 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6038 ],
6039 svg,
6040 )
6041 .into_response();
6042 };
6043
6044 let (default_label, value, default_color) = match metric.as_str() {
6045 "code-lines" => (
6046 "code lines",
6047 format_number(entry.summary.code_lines),
6048 "#4a78ee",
6049 ),
6050 "files" => (
6051 "files analyzed",
6052 format_number(entry.summary.files_analyzed),
6053 "#4a9862",
6054 ),
6055 "comment-lines" => (
6056 "comment lines",
6057 format_number(entry.summary.comment_lines),
6058 "#b35428",
6059 ),
6060 "blank-lines" => (
6061 "blank lines",
6062 format_number(entry.summary.blank_lines),
6063 "#7a5db0",
6064 ),
6065 _ => return StatusCode::NOT_FOUND.into_response(),
6066 };
6067
6068 let label = query.label.as_deref().unwrap_or(default_label);
6069 let color = query.color.as_deref().unwrap_or(default_color);
6070 let svg = render_badge_svg(label, &value, color);
6071
6072 (
6073 [
6074 (header::CONTENT_TYPE, "image/svg+xml"),
6075 (header::CACHE_CONTROL, "no-cache, max-age=0"),
6076 ],
6077 svg,
6078 )
6079 .into_response()
6080}
6081
6082#[derive(Serialize)]
6090struct ApiCoverageBlock {
6091 lines_found: u64,
6092 lines_hit: u64,
6093 line_pct: f64,
6094 functions_found: u64,
6095 functions_hit: u64,
6096 function_pct: f64,
6097 branches_found: u64,
6098 branches_hit: u64,
6099 branch_pct: f64,
6100}
6101
6102#[derive(Serialize)]
6103struct ApiMetricsResponse {
6104 run_id: String,
6105 timestamp: String,
6106 project: String,
6107 summary: ApiSummaryPayload,
6108 languages: Vec<ApiLanguageRow>,
6109 #[serde(skip_serializing_if = "Option::is_none")]
6110 coverage: Option<ApiCoverageBlock>,
6111}
6112
6113#[derive(Serialize)]
6114struct ApiSummaryPayload {
6115 files_analyzed: u64,
6116 files_skipped: u64,
6117 code_lines: u64,
6118 comment_lines: u64,
6119 blank_lines: u64,
6120 total_physical_lines: u64,
6121 functions: u64,
6122 classes: u64,
6123 variables: u64,
6124 imports: u64,
6125}
6126
6127#[derive(Serialize)]
6128struct ApiLanguageRow {
6129 name: String,
6130 files: u64,
6131 code_lines: u64,
6132 comment_lines: u64,
6133 blank_lines: u64,
6134 functions: u64,
6135 classes: u64,
6136 variables: u64,
6137 imports: u64,
6138}
6139
6140async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6141 let entry = {
6142 let reg = state.registry.lock().await;
6143 reg.entries.first().cloned()
6144 };
6145 entry.map_or_else(
6146 || error::not_found("no scans recorded yet"),
6147 |e| build_metrics_response(&e),
6148 )
6149}
6150
6151async fn api_metrics_run_handler(
6152 State(state): State<AppState>,
6153 AxumPath(run_id): AxumPath<String>,
6154) -> Response {
6155 let entry = {
6156 let reg = state.registry.lock().await;
6157 reg.find_by_run_id(&run_id).cloned()
6158 };
6159 entry.map_or_else(
6160 || error::not_found("run not found"),
6161 |e| build_metrics_response(&e),
6162 )
6163}
6164
6165fn build_metrics_response(entry: &RegistryEntry) -> Response {
6166 let languages: Vec<ApiLanguageRow> = entry
6167 .json_path
6168 .as_ref()
6169 .and_then(|p| read_json(p).ok())
6170 .map(|run| {
6171 run.totals_by_language
6172 .iter()
6173 .map(|l| ApiLanguageRow {
6174 name: l.language.display_name().to_string(),
6175 files: l.files,
6176 code_lines: l.code_lines,
6177 comment_lines: l.comment_lines,
6178 blank_lines: l.blank_lines,
6179 functions: l.functions,
6180 classes: l.classes,
6181 variables: l.variables,
6182 imports: l.imports,
6183 })
6184 .collect()
6185 })
6186 .unwrap_or_default();
6187
6188 let s = &entry.summary;
6189 let coverage = if s.coverage_lines_found > 0 {
6190 let pct = |hit: u64, found: u64| -> f64 {
6191 if found == 0 {
6192 0.0
6193 } else {
6194 #[allow(clippy::cast_precision_loss)]
6195 let v = (hit as f64 / found as f64) * 100.0;
6196 (v * 10.0).round() / 10.0
6197 }
6198 };
6199 Some(ApiCoverageBlock {
6200 lines_found: s.coverage_lines_found,
6201 lines_hit: s.coverage_lines_hit,
6202 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6203 functions_found: s.coverage_functions_found,
6204 functions_hit: s.coverage_functions_hit,
6205 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6206 branches_found: s.coverage_branches_found,
6207 branches_hit: s.coverage_branches_hit,
6208 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6209 })
6210 } else {
6211 None
6212 };
6213 Json(ApiMetricsResponse {
6214 run_id: entry.run_id.clone(),
6215 timestamp: entry.timestamp_utc.to_rfc3339(),
6216 project: entry.project_label.clone(),
6217 summary: ApiSummaryPayload {
6218 files_analyzed: s.files_analyzed,
6219 files_skipped: s.files_skipped,
6220 code_lines: s.code_lines,
6221 comment_lines: s.comment_lines,
6222 blank_lines: s.blank_lines,
6223 total_physical_lines: s.total_physical_lines,
6224 functions: s.functions,
6225 classes: s.classes,
6226 variables: s.variables,
6227 imports: s.imports,
6228 },
6229 languages,
6230 coverage,
6231 })
6232 .into_response()
6233}
6234
6235#[derive(Deserialize)]
6242struct ProjectHistoryQuery {
6243 path: Option<String>,
6244}
6245
6246#[derive(Serialize)]
6247struct ProjectHistoryResponse {
6248 scan_count: usize,
6249 last_scan_id: Option<String>,
6250 last_scan_timestamp: Option<String>,
6251 last_scan_code_lines: Option<u64>,
6252 last_git_branch: Option<String>,
6253 last_git_commit: Option<String>,
6254}
6255
6256fn entry_matches_project(
6259 entry: &RegistryEntry,
6260 root_str: &str,
6261 upload_root: &str,
6262 upload_name_suffix: Option<&str>,
6263) -> bool {
6264 if entry.input_roots.iter().any(|r| r == root_str) {
6265 return true;
6266 }
6267 if let Some(suffix) = upload_name_suffix {
6268 return entry
6269 .input_roots
6270 .iter()
6271 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6272 }
6273 false
6274}
6275
6276async fn project_history_handler(
6277 State(state): State<AppState>,
6278 Query(query): Query<ProjectHistoryQuery>,
6279) -> Response {
6280 let path = query.path.unwrap_or_default();
6281 let resolved = resolve_input_path(&path);
6282 let root_str = resolved.to_string_lossy().replace('\\', "/");
6283
6284 let upload_root = std::env::temp_dir()
6289 .join("oxide-sloc-uploads")
6290 .to_string_lossy()
6291 .replace('\\', "/");
6292 let upload_name_suffix: Option<String> =
6293 if state.server_mode && root_str.starts_with(&upload_root) {
6294 resolved
6295 .file_name()
6296 .and_then(|n| n.to_str())
6297 .map(|name| format!("/{name}"))
6298 } else {
6299 None
6300 };
6301 let suffix_ref = upload_name_suffix.as_deref();
6302
6303 let entries: Vec<_> = {
6304 let reg = state.registry.lock().await;
6305 reg.entries
6306 .iter()
6307 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6308 .cloned()
6309 .collect()
6310 };
6311 let scan_count = entries.len();
6312 let last = entries.first();
6313 let last_scan_id = last.map(|e| e.run_id.clone());
6314 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6315 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6316 let last_git_branch = last.and_then(|e| e.git_branch.clone());
6317 let last_git_commit = last.and_then(|e| e.git_commit.clone());
6318
6319 Json(ProjectHistoryResponse {
6320 scan_count,
6321 last_scan_id,
6322 last_scan_timestamp,
6323 last_scan_code_lines,
6324 last_git_branch,
6325 last_git_commit,
6326 })
6327 .into_response()
6328}
6329
6330#[derive(Deserialize)]
6337struct MetricsHistoryQuery {
6338 root: Option<String>,
6339 limit: Option<usize>,
6340 submodule: Option<String>,
6343}
6344
6345#[derive(Serialize)]
6346struct MetricsSubmoduleLink {
6347 name: String,
6348 url: String,
6349}
6350
6351#[derive(Serialize)]
6352struct MetricsHistoryEntry {
6353 run_id: String,
6354 run_id_short: String,
6355 timestamp: String,
6356 commit: Option<String>,
6357 branch: Option<String>,
6358 tags: Vec<String>,
6359 nearest_tag: Option<String>,
6360 code_lines: u64,
6361 comment_lines: u64,
6362 blank_lines: u64,
6363 physical_lines: u64,
6364 files_analyzed: u64,
6365 files_skipped: u64,
6366 test_count: u64,
6367 project_label: String,
6368 html_url: Option<String>,
6369 has_pdf: bool,
6370 submodule_links: Vec<MetricsSubmoduleLink>,
6371 #[serde(skip_serializing_if = "Option::is_none")]
6373 coverage_line_pct: Option<f64>,
6374}
6375
6376fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6377 let mut links: Vec<MetricsSubmoduleLink> = vec![];
6378 let sub_dir = e
6379 .html_path
6380 .as_ref()
6381 .and_then(|p| p.parent())
6382 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6383 let Some(dir) = sub_dir else { return links };
6384 let Ok(rd) = std::fs::read_dir(dir) else {
6385 return links;
6386 };
6387 for entry_res in rd.flatten() {
6388 let fname = entry_res.file_name();
6389 let fname_str = fname.to_string_lossy();
6390 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6391 let stem = &fname_str[..fname_str.len() - 5];
6392 let display = stem[4..].replace('-', " ");
6393 links.push(MetricsSubmoduleLink {
6394 name: display,
6395 url: format!("/runs/{stem}/{}", e.run_id),
6396 });
6397 }
6398 }
6399 links.sort_by(|a, b| a.name.cmp(&b.name));
6400 links
6401}
6402
6403fn apply_submodule_filter(
6404 base: MetricsHistoryEntry,
6405 filter: &str,
6406 e: &sloc_core::history::RegistryEntry,
6407) -> Option<MetricsHistoryEntry> {
6408 let json_path = e.json_path.as_ref()?;
6409 let json_str = std::fs::read_to_string(json_path).ok()?;
6410 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6411 let sub = run
6412 .submodule_summaries
6413 .iter()
6414 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6415 let safe = sanitize_project_label(&sub.name);
6416 let artifact_key = format!("sub_{safe}");
6417 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6418 || base.html_url.clone(),
6419 |run_dir| {
6420 let sub_path = run_dir.join(format!("{artifact_key}.html"));
6421 if sub_path.exists() {
6422 Some(format!("/runs/{artifact_key}/{}", e.run_id))
6423 } else {
6424 base.html_url.clone()
6425 }
6426 },
6427 );
6428 Some(MetricsHistoryEntry {
6429 code_lines: sub.code_lines,
6430 comment_lines: sub.comment_lines,
6431 blank_lines: sub.blank_lines,
6432 physical_lines: sub.total_physical_lines,
6433 files_analyzed: sub.files_analyzed,
6434 html_url: sub_html_url,
6435 has_pdf: false,
6436 submodule_links: vec![],
6437 ..base
6438 })
6439}
6440
6441#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
6443 State(state): State<AppState>,
6444 Query(query): Query<MetricsHistoryQuery>,
6445) -> Response {
6446 let limit = query.limit.unwrap_or(50).min(500);
6447 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6448
6449 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6450 let reg = state.registry.lock().await;
6451 reg.entries
6452 .iter()
6453 .filter(|e| {
6454 query.root.as_ref().is_none_or(|root| {
6455 let resolved = resolve_input_path(root);
6456 let root_str = resolved.to_string_lossy().replace('\\', "/");
6457 e.input_roots.iter().any(|r| r == &root_str)
6458 })
6459 })
6460 .take(limit)
6461 .cloned()
6462 .collect()
6463 };
6464
6465 let entries: Vec<MetricsHistoryEntry> = candidate_entries
6466 .into_iter()
6467 .filter_map(|e| {
6468 let tags = e
6469 .git_tags
6470 .as_deref()
6471 .map(|s| {
6472 s.split(',')
6473 .map(|t| t.trim().to_string())
6474 .filter(|t| !t.is_empty())
6475 .collect()
6476 })
6477 .unwrap_or_default();
6478 let html_url = e
6479 .html_path
6480 .as_ref()
6481 .filter(|p| p.exists())
6482 .map(|_| format!("/runs/html/{}", e.run_id));
6483 let nearest_tag = e.git_nearest_tag.clone();
6484 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6485 let run_id_short: String = e
6486 .run_id
6487 .split('-')
6488 .next_back()
6489 .unwrap_or(&e.run_id)
6490 .chars()
6491 .take(7)
6492 .collect();
6493 let submodule_links = build_entry_submodule_links(&e);
6494 #[allow(clippy::cast_precision_loss)]
6495 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6496 let pct = (e.summary.coverage_lines_hit as f64
6497 / e.summary.coverage_lines_found as f64)
6498 * 100.0;
6499 Some((pct * 10.0).round() / 10.0)
6500 } else {
6501 None
6502 };
6503 let base = MetricsHistoryEntry {
6504 run_id: e.run_id.clone(),
6505 run_id_short,
6506 timestamp: e.timestamp_utc.to_rfc3339(),
6507 commit: e.git_commit.clone(),
6508 branch: e.git_branch.clone(),
6509 tags,
6510 nearest_tag,
6511 code_lines: e.summary.code_lines,
6512 comment_lines: e.summary.comment_lines,
6513 blank_lines: e.summary.blank_lines,
6514 physical_lines: e.summary.total_physical_lines,
6515 files_analyzed: e.summary.files_analyzed,
6516 files_skipped: e.summary.files_skipped,
6517 test_count: e.summary.test_count,
6518 project_label: e.project_label.clone(),
6519 html_url,
6520 has_pdf,
6521 submodule_links,
6522 coverage_line_pct,
6523 };
6524 if let Some(ref filter) = submodule_filter {
6525 apply_submodule_filter(base, filter, &e)
6526 } else {
6527 Some(base)
6528 }
6529 })
6530 .collect();
6531
6532 Json(entries).into_response()
6533}
6534
6535#[derive(Deserialize)]
6539struct MetricsSubmodulesQuery {
6540 root: Option<String>,
6541}
6542
6543#[derive(Serialize)]
6544struct SubmoduleEntry {
6545 name: String,
6546 relative_path: String,
6547}
6548
6549async fn api_metrics_submodules_handler(
6550 State(state): State<AppState>,
6551 Query(query): Query<MetricsSubmodulesQuery>,
6552) -> Response {
6553 let json_paths: Vec<std::path::PathBuf> = {
6554 let reg = state.registry.lock().await;
6555 reg.entries
6556 .iter()
6557 .filter(|e| {
6558 query.root.as_ref().is_none_or(|root| {
6559 let resolved = resolve_input_path(root);
6560 let root_str = resolved.to_string_lossy().replace('\\', "/");
6561 e.input_roots.iter().any(|r| r == &root_str)
6562 })
6563 })
6564 .filter_map(|e| e.json_path.clone())
6565 .collect()
6566 };
6567
6568 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6569 let mut result: Vec<SubmoduleEntry> = Vec::new();
6570
6571 for path in &json_paths {
6572 let Ok(json_str) = std::fs::read_to_string(path) else {
6573 continue;
6574 };
6575 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6576 continue;
6577 };
6578 for sub in &run.submodule_summaries {
6579 if seen.insert(sub.name.clone()) {
6580 result.push(SubmoduleEntry {
6581 name: sub.name.clone(),
6582 relative_path: sub.relative_path.clone(),
6583 });
6584 }
6585 }
6586 }
6587
6588 result.sort_by(|a, b| a.name.cmp(&b.name));
6589 Json(result).into_response()
6590}
6591
6592#[derive(Deserialize)]
6601struct IngestQuery {
6602 label: Option<String>,
6603}
6604
6605#[derive(Serialize)]
6606struct IngestResponse {
6607 run_id: String,
6608 view_url: String,
6609}
6610
6611async fn api_ingest_handler(
6612 State(state): State<AppState>,
6613 Query(q): Query<IngestQuery>,
6614 Json(run): Json<sloc_core::AnalysisRun>,
6615) -> Response {
6616 let label = q.label.unwrap_or_else(|| {
6617 run.input_roots
6618 .first()
6619 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6620 });
6621
6622 let label_for_task = label.clone();
6623 let result = tokio::task::spawn_blocking(move || {
6624 let html = render_html(&run)?;
6625 let run_id = run.tool.run_id.clone();
6626 let run_id_safe = run_id.len() <= 128
6627 && !run_id.is_empty()
6628 && run_id
6629 .chars()
6630 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6631 if !run_id_safe {
6632 anyhow::bail!(
6633 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6634 );
6635 }
6636 let project_label = sanitize_project_label(&label_for_task);
6637 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6638 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6639 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6640 _ => project_label,
6641 };
6642 let (artifacts, _pending_pdf) = persist_run_artifacts(
6643 &run,
6644 &html,
6645 &output_dir,
6646 true,
6647 true,
6648 false,
6649 &label_for_task,
6650 &file_stem,
6651 RunResultContext::default(),
6652 )?;
6653 Ok::<_, anyhow::Error>((run_id, artifacts, run))
6654 })
6655 .await;
6656
6657 match result {
6658 Ok(Ok((run_id, artifacts, run))) => {
6659 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6660 (
6661 StatusCode::CREATED,
6662 Json(IngestResponse {
6663 view_url: format!("/view-reports?run_id={run_id}"),
6664 run_id,
6665 }),
6666 )
6667 .into_response()
6668 }
6669 Ok(Err(e)) => error::internal(&format!("{e:#}")),
6670 Err(e) => error::internal(&format!("{e}")),
6671 }
6672}
6673
6674#[allow(clippy::too_many_lines)] async fn trend_report_handler(
6682 State(state): State<AppState>,
6683 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6684) -> Response {
6685 auto_scan_watched_dirs(&state).await;
6686
6687 let watched_dirs_list: Vec<String> = {
6688 let wd = state.watched_dirs.lock().await;
6689 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6690 };
6691
6692 let roots: Vec<String> = {
6694 let reg = state.registry.lock().await;
6695 let mut seen = std::collections::BTreeSet::new();
6696 reg.entries
6697 .iter()
6698 .flat_map(|e| e.input_roots.iter().cloned())
6699 .filter(|r| seen.insert(r.clone()))
6700 .collect()
6701 };
6702
6703 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6704 let nonce = &csp_nonce;
6705 let version = env!("CARGO_PKG_VERSION");
6706
6707 let watched_dirs_html: String = if state.server_mode {
6711 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()
6712 } else {
6713 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6714 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6715 .to_string()
6716 } else {
6717 watched_dirs_list
6718 .iter()
6719 .fold(String::new(), |mut s, d| {
6720 use std::fmt::Write as _;
6721 let escaped =
6722 d.replace('&', "&").replace('"', """).replace('<', "<");
6723 write!(
6724 s,
6725 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>"#
6726 ).expect("write to String is infallible");
6727 s
6728 })
6729 };
6730 format!(
6731 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>"#
6732 )
6733 };
6734
6735 let html = format!(
6736 r##"<!doctype html>
6737<html lang="en">
6738<head>
6739 <meta charset="utf-8" />
6740 <meta name="viewport" content="width=device-width, initial-scale=1" />
6741 <title>OxideSLOC | Trend Reports</title>
6742 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6743 <style nonce="{nonce}">
6744 :root {{
6745 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6746 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6747 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6748 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6749 --info-bg:#eef3ff; --info-text:#4467d8;
6750 }}
6751 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
6752 *{{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;}}
6753 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
6754 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
6755 .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;}}
6756 @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));}}}}
6757 .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);}}
6758 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
6759 .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));}}
6760 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
6761 .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;}}
6762 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
6763 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
6764 @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; }} }}
6765 .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;}}
6766 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
6767 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
6768 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
6769 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
6770 .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;}}
6771 .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;}}
6772 .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;}}
6773 .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;}}
6774 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
6775 .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);}}
6776 .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;}}
6777 .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;}}
6778 .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;}}
6779 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
6780 .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;}}
6781 .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);}}
6782 .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;}}
6783 .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;}}
6784 .tz-select:focus{{border-color:var(--oxide);}}
6785 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
6786 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
6787 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
6788 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
6789 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
6790 .trend-title-block{{flex:1;min-width:0;}}
6791 .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;}}
6792 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
6793 .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;}}
6794 .chart-select:focus{{border-color:var(--accent);}}
6795 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
6796 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
6797 .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;}}
6798 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
6799 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
6800 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
6801 .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);}}
6802 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
6803 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
6804 .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;}}
6805 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
6806 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
6807 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
6808 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
6809 .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;}}
6810 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
6811 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
6812 .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);}}
6813 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
6814 .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;}}
6815 .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;}}
6816 .data-table tr:last-child td{{border-bottom:none;}}
6817 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
6818 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
6819 .table-wrap{{width:100%;overflow-x:auto;}}
6820 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
6821 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
6822 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
6823 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
6824 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
6825 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
6826 .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;}}
6827 .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;}}
6828 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
6829 .pagination-info{{font-size:13px;color:var(--muted);}}
6830 .pagination-btns{{display:flex;gap:6px;}}
6831 .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;}}
6832 .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;}}
6833 #scan-history-table col:nth-child(1){{width:155px;}}
6834 #scan-history-table col:nth-child(2){{width:240px;}}
6835 #scan-history-table col:nth-child(3){{width:82px;}}
6836 #scan-history-table col:nth-child(4){{width:82px;}}
6837 #scan-history-table col:nth-child(5){{width:90px;}}
6838 #scan-history-table col:nth-child(6){{width:90px;}}
6839 #scan-history-table col:nth-child(7){{width:88px;}}
6840 #scan-history-table col:nth-child(8){{width:150px;}}
6841 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
6842 .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;}}
6843 .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;}}
6844 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
6845 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
6846 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
6847 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
6848 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
6849 .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;}}
6850 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
6851 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
6852 .watched-chip-rm:hover{{color:var(--oxide);}}
6853 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
6854 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
6855 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
6856 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
6857 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
6858 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
6859 a.run-link:hover{{text-decoration:underline;}}
6860 .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);}}
6861 .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);}}
6862 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
6863 .metric-num{{font-weight:700;color:var(--text);}}
6864 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
6865 .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;}}
6866 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
6867 .btn.primary:hover{{opacity:.9;}}
6868 .rpt-btn{{min-width:58px;justify-content:center;}}
6869 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
6870 .report-cell{{overflow:visible!important;white-space:normal!important;}}
6871 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
6872 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
6873 .submod-details summary::-webkit-details-marker{{display:none;}}
6874 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
6875 .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;}}
6876 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
6877 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
6878 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
6879 .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;}}
6880 .export-btn:hover{{background:var(--line);}}
6881 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
6882 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
6883 .site-footer a{{color:var(--muted);}}
6884 .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;}}
6885 .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;}}
6886 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
6887 </style>
6888</head>
6889<body>
6890 <div class="background-watermarks" aria-hidden="true">
6891 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6892 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6893 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6894 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6895 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6896 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6897 </div>
6898 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6899 <div class="top-nav">
6900 <div class="top-nav-inner">
6901 <a class="brand" href="/">
6902 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6903 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
6904 </a>
6905 <div class="nav-right">
6906 <a class="nav-pill" href="/">Home</a>
6907 <div class="nav-dropdown">
6908 <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>
6909 <div class="nav-dropdown-menu">
6910 <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>
6911 </div>
6912 </div>
6913 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6914 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
6915 <div class="nav-dropdown">
6916 <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>
6917 <div class="nav-dropdown-menu">
6918 <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>
6919 </div>
6920 </div>
6921 <div class="server-status-wrap" id="server-status-wrap">
6922 <div class="nav-pill server-online-pill" id="server-status-pill">
6923 <span class="status-dot" id="status-dot"></span>
6924 <span id="server-status-label">Server</span>
6925 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
6926 </div>
6927 <div class="server-status-tip">
6928 OxideSLOC is running — accessible on your network.
6929 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
6930 </div>
6931 </div>
6932 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6933 <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>
6934 </button>
6935 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6936 <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>
6937 <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>
6938 </button>
6939 </div>
6940 </div>
6941 </div>
6942
6943 <div class="page">
6944 {watched_dirs_html}
6945 <div class="summary-strip" id="trend-stats"></div>
6946 <div class="panel">
6947 <div class="trend-header">
6948 <div class="trend-title-block">
6949 <h1>Trend Reports</h1>
6950 <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>
6951 <span class="chart-hint-inline">
6952 <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>
6953 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
6954 </span>
6955 </div>
6956 <div class="chart-actions">
6957 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
6958 <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>
6959 Clean up old runs
6960 </button>
6961 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
6962 <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>
6963 Export Excel
6964 </button>
6965 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
6966 <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>
6967 Export PNG
6968 </button>
6969 </div>
6970 </div>
6971
6972 <div class="controls-centered">
6973 <label>Project Root:
6974 <select class="chart-select" id="root-sel">
6975 <option value="">All projects</option>
6976 </select>
6977 </label>
6978 <label>Y Metric:
6979 <select class="chart-select" id="y-sel">
6980 <option value="code_lines">Code Lines</option>
6981 <option value="comment_lines">Comment Lines</option>
6982 <option value="blank_lines">Blank Lines</option>
6983 <option value="physical_lines">Physical Lines</option>
6984 <option value="files_analyzed">Files Analyzed</option>
6985 </select>
6986 </label>
6987 <label>X Axis:
6988 <select class="chart-select" id="x-sel">
6989 <option value="time">By Time</option>
6990 <option value="commit">By Commit</option>
6991 <option value="release">By Release</option>
6992 <option value="tag">Tagged Commits</option>
6993 </select>
6994 </label>
6995 <label id="submodule-label" style="display:none;">Submodule:
6996 <select class="chart-select" id="sub-sel">
6997 <option value="">All (project total)</option>
6998 </select>
6999 </label>
7000 <label>Chart Size:
7001 <select class="chart-select" id="scale-sel">
7002 <option value="0.75">Compact</option>
7003 <option value="1.2" selected>Normal</option>
7004 <option value="1.38">Large</option>
7005 </select>
7006 </label>
7007 </div>
7008
7009 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7010 <div id="data-table-wrap" style="overflow-x:auto;"></div>
7011 </div>
7012 </div>
7013
7014 <script nonce="{nonce}">
7015 (function() {{
7016 // Theme persistence
7017 var b = document.body;
7018 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7019 var tgl = document.getElementById('theme-toggle');
7020 if (tgl) tgl.addEventListener('click', function() {{
7021 var d = b.classList.toggle('dark-theme');
7022 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7023 }});
7024
7025 // Watermark randomizer
7026 (function() {{
7027 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7028 if (!wms.length) return;
7029 var placed = [];
7030 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;}}
7031 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];}}
7032 var half=Math.floor(wms.length/2);
7033 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;}});
7034 }})();
7035
7036 // Code particles
7037 (function() {{
7038 var container = document.getElementById('code-particles');
7039 if (!container) return;
7040 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'];
7041 for (var i = 0; i < 38; i++) {{
7042 (function(idx) {{
7043 var el = document.createElement('span');
7044 el.className = 'code-particle';
7045 el.textContent = snippets[idx % snippets.length];
7046 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7047 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7048 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7049 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';
7050 container.appendChild(el);
7051 }})(i);
7052 }}
7053 }})();
7054
7055 // Watched folder picker
7056 (function() {{
7057 var btn = document.getElementById('add-watched-btn');
7058 if (!btn) return;
7059 btn.addEventListener('click', function() {{
7060 fetch('/pick-directory?kind=reports')
7061 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7062 .then(function(data) {{
7063 if (!data.cancelled && data.selected_path) {{
7064 var form = document.createElement('form');
7065 form.method = 'POST';
7066 form.action = '/watched-dirs/add';
7067 var ri = document.createElement('input');
7068 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7069 var fi = document.createElement('input');
7070 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7071 form.appendChild(ri); form.appendChild(fi);
7072 document.body.appendChild(form);
7073 form.submit();
7074 }}
7075 }})
7076 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7077 }});
7078 }})();
7079
7080 // Settings / color-scheme modal
7081 (function() {{
7082 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'}}];
7083 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);}});}}
7084 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7085 var btn=document.getElementById('settings-btn');if(!btn)return;
7086 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7087 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>';
7088 document.body.appendChild(m);
7089 var g=document.getElementById('scheme-grid');
7090 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);}});
7091 var cl=document.getElementById('settings-close');
7092 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);
7093 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');}});
7094 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7095 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7096 }})();
7097 }})();
7098
7099 var ROOTS = {roots_json};
7100 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7101 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7102 var allData = [];
7103
7104 // Populate root selector
7105 var rootSel = document.getElementById('root-sel');
7106 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7107
7108 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();}}
7109 function fmtFull(n){{return Number(n).toLocaleString();}}
7110 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
7111
7112 // Tooltip
7113 var tt = document.createElement('div');
7114 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);';
7115 document.body.appendChild(tt);
7116 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7117 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';}}
7118 function hideTT(){{tt.style.display='none';}}
7119
7120 function statExact(compact, full){{
7121 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7122 }}
7123 function statVal(n){{
7124 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7125 }}
7126
7127 function updateStats(data){{
7128 var statsEl=document.getElementById('trend-stats');
7129 if(!statsEl)return;
7130 if(!data||!data.length){{statsEl.innerHTML='';return;}}
7131 var yKey=document.getElementById('y-sel').value;
7132 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7133 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7134 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7135 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7136 var absDelta=Math.abs(delta);
7137 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7138 var deltaExact=statExact(deltaCompact,deltaFull);
7139 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7140 statsEl.innerHTML=
7141 '<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>'+
7142 '<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>'+
7143 '<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>'+
7144 '<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>';
7145 }}
7146
7147 var subSel = document.getElementById('sub-sel');
7148 var subLabel = document.getElementById('submodule-label');
7149
7150 function populateSubmodules(root){{
7151 if(!subSel||!subLabel)return;
7152 while(subSel.options.length>1)subSel.remove(1);
7153 subSel.value='';
7154 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7155 fetch(url)
7156 .then(function(r){{return r.json();}})
7157 .then(function(subs){{
7158 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7159 subs.forEach(function(s){{
7160 var o=document.createElement('option');
7161 o.value=s.name;
7162 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7163 subSel.appendChild(o);
7164 }});
7165 subLabel.style.display='';
7166 }})
7167 .catch(function(){{subLabel.style.display='none';}});
7168 }}
7169
7170 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
7171
7172 function loadAndRender(){{
7173 var root = rootSel.value;
7174 var sub = subSel ? subSel.value : '';
7175 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7176 document.getElementById('data-table-wrap').innerHTML='';
7177 var url = '/api/metrics/history?limit=100'
7178 + (root ? '&root='+encodeURIComponent(root) : '')
7179 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
7180 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7181 allData = data;
7182 render(data);
7183 updateStats(data);
7184 }}).catch(function(){{
7185 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>';
7186 }});
7187 }}
7188
7189 function render(data){{
7190 var yKey = document.getElementById('y-sel').value;
7191 var xMode = document.getElementById('x-sel').value;
7192
7193 // Filter for tag/release mode
7194 var pts = data;
7195 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7196
7197 // Sort oldest-first for the line chart
7198 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7199
7200 var wrap = document.getElementById('chart-wrap');
7201 if(!pts.length){{
7202 var emptyMsg = (xMode === 'tag')
7203 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7204 : 'No scan data found for the selected filters.';
7205 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7206 renderTable([]);
7207 return;
7208 }}
7209
7210 var scaleEl=document.getElementById('scale-sel');
7211 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7212 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;
7213 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7214
7215 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7216
7217 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">';
7218 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>';
7219
7220 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7221
7222 // Grid + Y axis ticks
7223 for(var ti=0;ti<=5;ti++){{
7224 var gy=PT+CH-Math.round(ti/5*CH);
7225 var gv=Math.round(ti/5*maxY);
7226 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7227 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7228 }}
7229
7230 // X axis labels (every N-th point to avoid crowding)
7231 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7232 pts.forEach(function(d,i){{
7233 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7234 if(i%labelEvery===0||i===pts.length-1){{
7235 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)));
7236 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>';
7237 }}
7238 }});
7239
7240 // Axis label
7241 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7242 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>';
7243 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>';
7244
7245 // Area fill + line path
7246 var pathD='';
7247 pts.forEach(function(d,i){{
7248 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7249 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7250 pathD+=(i===0?'M':'L')+x+','+y;
7251 }});
7252 if(pts.length>1){{
7253 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7254 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7255 }}
7256 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7257
7258 // Data points (clickable) + permanent value labels
7259 var showLabels = pts.length <= 40;
7260 var labelEveryN = pts.length > 20 ? 2 : 1;
7261 pts.forEach(function(d,i){{
7262 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7263 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7264 var hasTags=d.tags&&d.tags.length>0;
7265 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7266 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7267 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+'"/>';
7268 if(showLabels && i%labelEveryN===0){{
7269 var lx=x, ly=y-r-5;
7270 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>';
7271 }}
7272 }});
7273
7274 svg+='</svg>';
7275 wrap.innerHTML=svg;
7276
7277 // Attach point tooltips
7278 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7279 c.addEventListener('mouseover',function(e){{
7280 var d=pts[parseInt(this.dataset.idx)];
7281 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(''):'';
7282 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>':'';
7283 showTT(e,
7284 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7285 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7286 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7287 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7288 );
7289 this.setAttribute('r','8');
7290 }});
7291 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7292 c.addEventListener('mousemove',moveTT);
7293 c.addEventListener('click',function(){{
7294 var d=pts[parseInt(this.dataset.idx)];
7295 if(d.html_url) window.open(d.html_url,'_blank');
7296 }});
7297 }});
7298
7299 renderTable(pts, yKey);
7300 }}
7301
7302 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7303 var shProjFilter='', shBranchFilter='';
7304
7305 function fmtPST(isoStr){{
7306 if(!isoStr)return'';
7307 var d=new Date(isoStr);
7308 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7309 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);}}
7310 function p(n){{return n<10?'0'+n:String(n);}}
7311 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++;}}}}
7312 var yr=d.getUTCFullYear();
7313 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7314 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7315 var isDST=d>=dstStart&&d<dstEnd;
7316 var off=isDST?-7*3600*1000:-8*3600*1000;
7317 var lbl=isDST?'PDT':'PST';
7318 var loc=new Date(d.getTime()+off);
7319 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7320 }}
7321
7322 function getShRows(){{
7323 var proj=shProjFilter.toLowerCase().trim();
7324 var branch=shBranchFilter;
7325 return shData.filter(function(d){{
7326 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7327 if(branch&&(d.branch||'')!==branch)return false;
7328 return true;
7329 }});
7330 }}
7331
7332 function renderShPage(){{
7333 var filtered=getShRows();
7334 if(shSortCol){{
7335 filtered.sort(function(a,b){{
7336 var va,vb;
7337 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7338 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7339 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7340 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7341 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7342 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7343 }});
7344 }}
7345 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7346 shPage=Math.min(shPage,totalPages);
7347 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7348 var visible=filtered.slice(start,end);
7349 var tbody=document.getElementById('sh-tbody');
7350 if(!tbody)return;
7351 tbody.innerHTML=visible.map(function(d){{
7352 var tsHtml=esc(fmtPST(d.timestamp));
7353 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>';
7354 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>';
7355 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
7356 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
7357 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7358 var reportCell='';
7359 if(d.html_url){{
7360 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7361 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>';}}
7362 reportCell+='</div>';
7363 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
7364 if(d.submodule_links&&d.submodule_links.length){{
7365 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7366 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7367 reportCell+='</div></details>';
7368 }}
7369 return '<tr>'
7370 +'<td>'+tsHtml+'</td>'
7371 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7372 +'<td>'+runIdHtml+'</td>'
7373 +'<td>'+commitHtml+'</td>'
7374 +'<td>'+branchHtml+'</td>'
7375 +'<td>'+tags+'</td>'
7376 +'<td class="num">'+metricHtml+'</td>'
7377 +'<td class="report-cell">'+reportCell+'</td>'
7378 +'</tr>';
7379 }}).join('');
7380 var pgRange=document.getElementById('sh-pg-range');
7381 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
7382 var pgInfo=document.getElementById('sh-pg-info');
7383 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7384 var pgBtns=document.getElementById('sh-pg-btns');
7385 if(pgBtns){{
7386 pgBtns.innerHTML='';
7387 function mkPgBtn(lbl,pg,active,disabled){{
7388 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7389 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7390 return b;
7391 }}
7392 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
7393 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7394 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7395 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
7396 }}
7397 }}
7398
7399 function wireTableBehavior(){{
7400 var pf=document.getElementById('sh-proj-filter');
7401 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7402 var bf=document.getElementById('sh-branch-filter');
7403 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7404 var rb=document.getElementById('sh-reset-btn');
7405 if(rb)rb.addEventListener('click',function(){{
7406 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7407 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7408 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7409 document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7410 renderShPage();
7411 }});
7412 var pps=document.getElementById('sh-per-page');
7413 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7414 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7415 ths.forEach(function(th){{
7416 th.addEventListener('click',function(e){{
7417 if(e.target.classList.contains('col-resize-handle'))return;
7418 var col=th.dataset.col;
7419 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7420 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7421 th.classList.add('sort-'+shSortOrder);
7422 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
7423 shPage=1;renderShPage();
7424 }});
7425 }});
7426 var table=document.getElementById('scan-history-table');
7427 if(!table)return;
7428 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7429 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7430 allThs.forEach(function(th,i){{
7431 var handle=th.querySelector('.col-resize-handle');
7432 if(!handle||!cols[i])return;
7433 var startX,startW;
7434 handle.addEventListener('mousedown',function(e){{
7435 e.stopPropagation();e.preventDefault();
7436 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7437 handle.classList.add('dragging');
7438 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7439 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7440 document.addEventListener('mousemove',onMove);
7441 document.addEventListener('mouseup',onUp);
7442 }});
7443 }});
7444 }}
7445
7446 function renderTable(pts, yKey){{
7447 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7448 var wrap=document.getElementById('data-table-wrap');
7449 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7450 var yLabel=Y_LABELS[yKey]||yKey||'';
7451 shData=pts.slice().reverse();
7452 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7453 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7454 var branches={{}};
7455 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7456 var branchOpts='<option value="">All branches</option>';
7457 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7458 wrap.innerHTML=
7459 '<div class="chart-section-header">SCAN HISTORY</div>'+
7460 '<div class="filter-row">'+
7461 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project\u2026">'+
7462 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7463 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
7464 '</div>'+
7465 '<div class="table-wrap">'+
7466 '<table id="scan-history-table" class="data-table">'+
7467 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7468 '<thead><tr id="sh-thead">'+
7469 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7470 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7471 '<th>Run ID<div class="col-resize-handle"></div></th>'+
7472 '<th>Commit<div class="col-resize-handle"></div></th>'+
7473 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7474 '<th>Tags<div class="col-resize-handle"></div></th>'+
7475 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
7476 '<th>Report<div class="col-resize-handle"></div></th>'+
7477 '</tr></thead>'+
7478 '<tbody id="sh-tbody"></tbody>'+
7479 '</table>'+
7480 '</div>'+
7481 '<div class="pagination">'+
7482 '<span class="pagination-info" id="sh-pg-info"></span>'+
7483 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7484 '<div style="display:flex;align-items:center;gap:8px;">'+
7485 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7486 '<select class="filter-select" id="sh-per-page">'+
7487 '<option value="10">10 per page</option>'+
7488 '<option value="25" selected>25 per page</option>'+
7489 '<option value="50">50 per page</option>'+
7490 '<option value="100">100 per page</option>'+
7491 '</select>'+
7492 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7493 '</div>'+
7494 '</div>';
7495 wireTableBehavior();
7496 renderShPage();
7497 }}
7498
7499 function exportXLSX(){{
7500 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7501 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7502 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7503 var s1R=sorted.map(function(d){{
7504 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||''];
7505 }});
7506 var pm={{}};
7507 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7508 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'];
7509 var s2R=Object.keys(pm).map(function(p){{
7510 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7511 var lat=sc[sc.length-1],fst=sc[0];
7512 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7513 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);
7514 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];
7515 }});
7516 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7517 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7518 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7519 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7520 }}
7521
7522 function buildXLSX(sheets,chartRows,chartRows2){{
7523 function s2b(s){{return new TextEncoder().encode(s);}}
7524 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
7525 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;}}
7526 function crc32(d){{
7527 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;}}}}
7528 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7529 }}
7530 function buildSheet(hdr,rows,drawRid,withCtrl){{
7531 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7532 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7533 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7534 x+='<row r="1">';
7535 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7536 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>';}}
7537 x+='</row>';
7538 rows.forEach(function(row,ri){{
7539 var rn=ri+2;
7540 x+='<row r="'+rn+'">';
7541 row.forEach(function(cell,ci){{
7542 var addr=col2l(ci+1)+rn;
7543 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7544 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7545 }});
7546 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>';}}
7547 x+='</row>';
7548 }});
7549 x+='</sheetData>';
7550 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>';}}
7551 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7552 return x+'</worksheet>';
7553 }}
7554 function buildChartXML(rows){{
7555 var sn="'Scan History'";
7556 var nr=rows.length,er=nr+1;
7557 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'}}];
7558 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7559 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">';
7560 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7561 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7562 sd.forEach(function(s,i){{
7563 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7564 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>';
7565 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7566 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>';
7567 var dlp=(i===2)?'b':'t';
7568 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>';
7569 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7570 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7571 x+='</c:strCache></c:strRef></c:cat>';
7572 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+'"/>';
7573 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7574 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7575 }});
7576 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7577 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>';
7578 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>';
7579 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7580 return x;
7581 }}
7582 function buildChartXML2(rows){{
7583 var sn="'By Project'";
7584 var nr=rows.length,er=nr+1;
7585 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'}}];
7586 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7587 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">';
7588 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7589 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7590 sd.forEach(function(s,i){{
7591 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7592 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>';
7593 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7594 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>';
7595 var dlp=(i===2)?'b':'t';
7596 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>';
7597 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7598 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7599 x+='</c:strCache></c:strRef></c:cat>';
7600 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+'"/>';
7601 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7602 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7603 }});
7604 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7605 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>';
7606 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>';
7607 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7608 return x;
7609 }}
7610 function buildChartXML3(rows){{
7611 var sn="'Scan History'";
7612 var nr=rows.length,er=nr+1;
7613 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7614 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">';
7615 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7616 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7617 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7618 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>';
7619 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7620 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>';
7621 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>';
7622 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7623 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7624 x+='</c:strCache></c:strRef></c:cat>';
7625 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+'"/>';
7626 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7627 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7628 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7629 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>';
7630 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>';
7631 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>';
7632 return x;
7633 }}
7634 var hasChart=!!(chartRows&&chartRows.length);
7635 var nr=hasChart?chartRows.length:0;
7636 var hasChart2=!!(chartRows2&&chartRows2.length);
7637 var nr2=hasChart2?chartRows2.length:0;
7638 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>';
7639 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"/>';
7640 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7641 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"/>';}}
7642 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"/>';}}
7643 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7644 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>';
7645 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7646 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"/>';}});
7647 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7648 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>';
7649 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7650 wbx+='</sheets></workbook>';
7651 var files=[
7652 {{name:'[Content_Types].xml',data:s2b(ct)}},
7653 {{name:'_rels/.rels',data:s2b(dotrels)}},
7654 {{name:'xl/workbook.xml',data:s2b(wbx)}},
7655 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7656 {{name:'xl/styles.xml',data:s2b(styl)}}
7657 ];
7658 // Chart embedded directly in Scan History (sheet1); By Project is plain
7659 sheets.forEach(function(s,i){{
7660 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)))}});
7661 }});
7662 if(hasChart){{
7663 var fromRow=nr+4,toRow=nr+24;
7664 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>')}});
7665 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7666 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">';
7667 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7668 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>';
7669 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>';
7670 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7671 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7672 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7673 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7674 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7675 var focRow=toRow+2,focRowEnd=toRow+22;
7676 drx+='<xdr:twoCellAnchor editAs="twoCell">';
7677 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>';
7678 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>';
7679 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7680 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7681 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7682 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7683 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7684 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7685 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>')}});
7686 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7687 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7688 }}
7689 if(hasChart2){{
7690 var fromRow2=nr2+4,toRow2=nr2+24;
7691 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>')}});
7692 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7693 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">';
7694 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7695 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>';
7696 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>';
7697 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7698 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7699 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7700 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7701 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7702 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7703 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>')}});
7704 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7705 }}
7706 var parts=[],offsets=[],total=0;
7707 files.forEach(function(f){{
7708 offsets.push(total);
7709 var nb=s2b(f.name),crc=crc32(f.data);
7710 var h=new DataView(new ArrayBuffer(30+nb.length));
7711 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7712 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7713 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7714 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7715 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7716 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7717 total+=30+nb.length+f.data.length;
7718 }});
7719 var cdStart=total;
7720 files.forEach(function(f,fi){{
7721 var nb=s2b(f.name),crc=crc32(f.data);
7722 var cd=new DataView(new ArrayBuffer(46+nb.length));
7723 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7724 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7725 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7726 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7727 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7728 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7729 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7730 }});
7731 var cdSz=total-cdStart;
7732 var eocd=new DataView(new ArrayBuffer(22));
7733 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7734 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7735 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7736 parts.push(new Uint8Array(eocd.buffer));
7737 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7738 var out=new Uint8Array(sz);var off=0;
7739 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7740 return out.buffer;
7741 }}
7742
7743 function exportPNG(){{
7744 var svgEl=document.querySelector('#chart-wrap svg');
7745 if(!svgEl){{alert('No chart to export yet.');return;}}
7746 var svgStr=new XMLSerializer().serializeToString(svgEl);
7747 var vb=svgEl.viewBox.baseVal,scale=2;
7748 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7749 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7750 var url=URL.createObjectURL(blob);
7751 var img=new Image();
7752 img.onload=function(){{
7753 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
7754 var ctx=canvas.getContext('2d');
7755 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
7756 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
7757 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
7758 URL.revokeObjectURL(url);
7759 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
7760 }};
7761 img.src=url;
7762 }}
7763
7764 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
7765 var el=document.getElementById(id);
7766 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
7767 }});
7768 rootSel.addEventListener('change',function(){{
7769 populateSubmodules(rootSel.value);
7770 loadAndRender();
7771 }});
7772 if(subSel)subSel.addEventListener('change',loadAndRender);
7773
7774 var xlsxBtn=document.getElementById('export-xlsx-btn');
7775 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
7776 var pngBtn=document.getElementById('export-png-btn');
7777 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
7778
7779 // ── Clean-up modal ───────────────────────────────────────────────────────
7780 (function(){{
7781 var triggerBtn=document.getElementById('cleanup-runs-btn');
7782 if(!triggerBtn)return;
7783 var modal=document.createElement('div');
7784 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;';
7785 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);">'
7786 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
7787 +'<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>'
7788 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
7789 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
7790 +'<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;">'
7791 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
7792 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
7793 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
7794 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
7795 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
7796 +'</div></div>';
7797 document.body.appendChild(modal);
7798 triggerBtn.addEventListener('click',function(){{
7799 document.getElementById('cleanup-status').style.display='none';
7800 modal.style.display='flex';
7801 }});
7802 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
7803 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
7804 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
7805 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
7806 var confirmBtn=this;
7807 confirmBtn.disabled=true;
7808 var status=document.getElementById('cleanup-status');
7809 status.style.display='block';
7810 status.style.background='#dbeafe';status.style.color='#1e40af';
7811 status.textContent='Deleting\u2026';
7812 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
7813 .then(function(resp){{
7814 return resp.json().then(function(d){{
7815 if(resp.ok){{
7816 status.style.background='#dcfce7';status.style.color='#166534';
7817 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
7818 setTimeout(function(){{window.location.reload();}},1500);
7819 }}else{{
7820 status.style.background='#fee2e2';status.style.color='#991b1b';
7821 status.textContent='Error: '+(d.error||'Unexpected error');
7822 confirmBtn.disabled=false;
7823 }}
7824 }});
7825 }})
7826 .catch(function(e){{
7827 status.style.background='#fee2e2';status.style.color='#991b1b';
7828 status.textContent='Network error: '+String(e);
7829 confirmBtn.disabled=false;
7830 }});
7831 }});
7832 }})();
7833
7834 populateSubmodules(rootSel.value);
7835 loadAndRender();
7836
7837 (function randomizeWatermarks() {{
7838 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7839 if (!wms.length) return;
7840 var placed = [];
7841 function tooClose(top, left) {{
7842 for (var i = 0; i < placed.length; i++) {{
7843 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
7844 if (dt < 16 && dl < 12) return true;
7845 }}
7846 return false;
7847 }}
7848 function pick(leftBand) {{
7849 for (var attempt = 0; attempt < 50; attempt++) {{
7850 var top = Math.random() * 88 + 2;
7851 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7852 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
7853 }}
7854 var top = Math.random() * 88 + 2;
7855 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7856 placed.push([top, left]); return [top, left];
7857 }}
7858 var half = Math.floor(wms.length / 2);
7859 wms.forEach(function (img, i) {{
7860 var pos = pick(i < half);
7861 var size = Math.floor(Math.random() * 100 + 120);
7862 var rot = (Math.random() * 360).toFixed(1);
7863 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
7864 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;
7865 }});
7866 }})();
7867 (function spawnCodeParticles() {{
7868 var container = document.getElementById('code-particles');
7869 if (!container) return;
7870 var snippets = [
7871 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
7872 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
7873 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
7874 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
7875 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
7876 ];
7877 var count = 38;
7878 for (var i = 0; i < count; i++) {{
7879 (function(idx) {{
7880 var el = document.createElement('span');
7881 el.className = 'code-particle';
7882 el.textContent = snippets[idx % snippets.length];
7883 var left = Math.random() * 94 + 2;
7884 var top = Math.random() * 88 + 6;
7885 var dur = (Math.random() * 10 + 9).toFixed(1);
7886 var delay = (Math.random() * 18).toFixed(1);
7887 var rot = (Math.random() * 26 - 13).toFixed(1);
7888 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7889 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
7890 container.appendChild(el);
7891 }})(i);
7892 }}
7893 }})();
7894 </script>
7895 <footer class="site-footer">
7896 local code analysis - metrics, history and reports
7897 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
7898 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7899 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7900 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7901 · <a href="/api-docs" rel="noopener">REST API</a>
7902 </footer>
7903 <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>
7904</body>
7905</html>"##,
7906 );
7907
7908 Html(html).into_response()
7909}
7910
7911fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7912 use std::collections::HashMap;
7913 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
7914 return vec![];
7915 }
7916 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7917 for rec in per_file_records {
7918 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7919 let e = totals.entry(lang.display_name().to_string()).or_default();
7920 e.0 += u64::from(cov.lines_found);
7921 e.1 += u64::from(cov.lines_hit);
7922 }
7923 }
7924 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
7926 .into_iter()
7927 .filter(|(_, (found, _))| *found > 0)
7928 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7929 .collect();
7930 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7931 pairs
7932 .iter()
7933 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
7934 .collect()
7935}
7936
7937fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
7938 let mut high = 0u64;
7939 let mut mid = 0u64;
7940 let mut low = 0u64;
7941 for rec in per_file_records {
7942 if let Some(cov) = &rec.coverage {
7943 if cov.lines_found == 0 {
7944 continue;
7945 }
7946 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7947 if pct >= 80.0 {
7948 high += 1;
7949 } else if pct >= 50.0 {
7950 mid += 1;
7951 } else {
7952 low += 1;
7953 }
7954 }
7955 }
7956 (high, mid, low)
7957}
7958
7959fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7960 let mut arr: Vec<serde_json::Value> = per_file_records
7961 .iter()
7962 .filter_map(|rec| {
7963 rec.coverage.as_ref().map(|cov| {
7964 let line_pct = if cov.lines_found > 0 {
7965 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
7966 / 10.0
7967 } else {
7968 0.0
7969 };
7970 let fn_pct = if cov.functions_found > 0 {
7971 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
7972 .round()
7973 / 10.0
7974 } else {
7975 -1.0
7976 };
7977 serde_json::json!({
7978 "rel": rec.relative_path,
7979 "lang": rec.language.map_or("?", |l| l.display_name()),
7980 "line_pct": line_pct,
7981 "fn_pct": fn_pct,
7982 "lhit": cov.lines_hit,
7983 "lfound": cov.lines_found,
7984 "fhit": cov.functions_hit,
7985 "ffound": cov.functions_found,
7986 })
7987 })
7988 })
7989 .collect();
7990 arr.sort_by(|a, b| {
7991 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7992 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7993 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7994 });
7995 arr
7996}
7997
7998#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
8000 let mut langs: Vec<&sloc_core::LanguageSummary> = run
8001 .totals_by_language
8002 .iter()
8003 .filter(|l| l.test_count > 0)
8004 .collect();
8005 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8006 let lang_tests: Vec<serde_json::Value> = langs
8007 .iter()
8008 .map(|l| {
8009 let d = if l.code_lines > 0 {
8010 l.test_count as f64 / l.code_lines as f64 * 1000.0
8011 } else {
8012 0.0
8013 };
8014 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8015 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8016 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8017 })
8018 .collect();
8019 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
8020 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8021 let t = &run.summary_totals;
8022 let total_tests = t.test_count;
8023 let density = if t.code_lines > 0 {
8024 total_tests as f64 / t.code_lines as f64 * 1000.0
8025 } else {
8026 0.0
8027 };
8028 let most_tested = langs.first().map_or_else(
8029 || "\u{2014}".to_string(),
8030 |l| l.language.display_name().to_string(),
8031 );
8032 let test_files: u64 = run
8033 .per_file_records
8034 .iter()
8035 .filter(|f| f.raw_line_categories.test_count > 0)
8036 .count() as u64;
8037 let cov_line = if t.coverage_lines_found > 0 {
8038 format!(
8039 "{:.1}",
8040 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8041 )
8042 } else {
8043 "0".to_string()
8044 };
8045 let cov_fn = if t.coverage_functions_found > 0 {
8046 format!(
8047 "{:.1}",
8048 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8049 )
8050 } else {
8051 "0".to_string()
8052 };
8053 let cov_branch = if t.coverage_branches_found > 0 {
8054 format!(
8055 "{:.1}",
8056 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8057 )
8058 } else {
8059 "0".to_string()
8060 };
8061 let has_cov = !cov_arr.is_empty();
8062 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8063 serde_json::json!({
8064 "totals": {
8065 "test_count": total_tests,
8066 "assertions": t.test_assertion_count,
8067 "suites": t.test_suite_count,
8068 "test_files": test_files,
8069 "total_files": t.files_analyzed,
8070 "density_str": format!("{density:.1}"),
8071 "most_tested": most_tested,
8072 "langs_with_tests": langs.len(),
8073 "cov_line": cov_line,
8074 "cov_fn": cov_fn,
8075 "cov_branch": cov_branch,
8076 },
8077 "lang_tests": lang_tests,
8078 "cov": cov_arr,
8079 "cov_tiers": {"high": high, "mid": mid, "low": low},
8080 "file_cov": file_cov_arr,
8081 "has_coverage": has_cov,
8082 "submodules": {},
8083 })
8084}
8085
8086#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8088 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8089 .language_summaries
8090 .iter()
8091 .filter(|l| l.test_count > 0)
8092 .collect();
8093 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8094 let lang_tests: Vec<serde_json::Value> = langs
8095 .iter()
8096 .map(|l| {
8097 let d = if l.code_lines > 0 {
8098 l.test_count as f64 / l.code_lines as f64 * 1000.0
8099 } else {
8100 0.0
8101 };
8102 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8103 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8104 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8105 })
8106 .collect();
8107 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8108 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8109 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8110 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8111 let density = if sub.code_lines > 0 {
8112 total_tests as f64 / sub.code_lines as f64 * 1000.0
8113 } else {
8114 0.0
8115 };
8116 let most_tested = langs.first().map_or_else(
8117 || "\u{2014}".to_string(),
8118 |l| l.language.display_name().to_string(),
8119 );
8120 serde_json::json!({
8121 "totals": {
8122 "test_count": total_tests,
8123 "assertions": total_assertions,
8124 "suites": total_suites,
8125 "test_files": test_files_approx,
8126 "total_files": sub.files_analyzed,
8127 "density_str": format!("{density:.1}"),
8128 "most_tested": most_tested,
8129 "langs_with_tests": langs.len(),
8130 "cov_line": "0",
8131 "cov_fn": "0",
8132 "cov_branch": "0",
8133 },
8134 "lang_tests": lang_tests,
8135 "cov": [],
8136 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8137 "has_coverage": false,
8138 })
8139}
8140
8141fn compute_cov_json_str(run: &AnalysisRun) -> String {
8142 use std::collections::HashMap;
8143 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8144 for rec in &run.per_file_records {
8145 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8146 let e = totals.entry(lang.display_name().to_string()).or_default();
8147 e.0 += u64::from(cov.lines_found);
8148 e.1 += u64::from(cov.lines_hit);
8149 }
8150 }
8151 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
8153 .into_iter()
8154 .filter(|(_, (found, _))| *found > 0)
8155 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8156 .collect();
8157 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8158 let parts: Vec<String> = pairs
8159 .iter()
8160 .map(|(lang, pct)| {
8161 let name = lang.replace('"', "\\\"");
8162 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8163 })
8164 .collect();
8165 format!("[{}]", parts.join(","))
8166}
8167
8168fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8169 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8170 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8171}
8172
8173fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8174 let mut entry = build_test_scope_entry(run);
8175 if !run.submodule_summaries.is_empty() {
8176 let subs: serde_json::Map<String, serde_json::Value> = run
8177 .submodule_summaries
8178 .iter()
8179 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8180 .collect();
8181 entry["submodules"] = serde_json::Value::Object(subs);
8182 }
8183 entry
8184}
8185
8186fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8187 let name = l.language.display_name().replace('"', "\\\"");
8188 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
8190 l.test_count as f64 / l.code_lines as f64 * 1000.0
8191 } else {
8192 0.0
8193 };
8194 format!(
8195 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8196 name = name,
8197 t = l.test_count,
8198 a = l.test_assertion_count,
8199 s = l.test_suite_count,
8200 c = l.code_lines,
8201 d = density,
8202 f = l.files,
8203 )
8204}
8205
8206fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8207 let Some(r) = run else {
8208 return "[]".to_string();
8209 };
8210 let mut langs: Vec<&sloc_core::LanguageSummary> = r
8211 .totals_by_language
8212 .iter()
8213 .filter(|l| l.test_count > 0)
8214 .collect();
8215 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8216 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8217 format!("[{}]", parts.join(","))
8218}
8219
8220#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
8224 State(state): State<AppState>,
8225 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8226) -> Response {
8227 auto_scan_watched_dirs(&state).await;
8228 let watched_dirs_list: Vec<String> = {
8229 let wd = state.watched_dirs.lock().await;
8230 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8231 };
8232 let latest_run: Option<AnalysisRun> = {
8233 let reg = state.registry.lock().await;
8234 let json_str: Option<String> = reg
8235 .entries
8236 .first()
8237 .and_then(|e| e.json_path.as_ref())
8238 .and_then(|p| std::fs::read_to_string(p).ok());
8239 drop(reg);
8240 json_str
8241 .as_deref()
8242 .and_then(|s| serde_json::from_str(s).ok())
8243 };
8244
8245 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8247
8248 let cov_json: String = latest_run
8250 .as_ref()
8251 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8252 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8253
8254 let _cov_tier_json: String = latest_run
8256 .as_ref()
8257 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8258 .map_or_else(
8259 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8260 compute_cov_tier_json_str,
8261 );
8262
8263 let total_tests: u64 = latest_run
8264 .as_ref()
8265 .map_or(0, |r| r.summary_totals.test_count);
8266 let total_assertions: u64 = latest_run
8267 .as_ref()
8268 .map_or(0, |r| r.summary_totals.test_assertion_count);
8269 let total_suites: u64 = latest_run
8270 .as_ref()
8271 .map_or(0, |r| r.summary_totals.test_suite_count);
8272 let total_code: u64 = latest_run
8273 .as_ref()
8274 .map_or(0, |r| r.summary_totals.code_lines);
8275 let workspace_density: f64 = if total_code > 0 {
8276 total_tests as f64 / total_code as f64 * 1000.0
8277 } else {
8278 0.0
8279 };
8280 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8281 r.totals_by_language
8282 .iter()
8283 .filter(|l| l.test_count > 0)
8284 .count()
8285 });
8286 let most_tested: String = latest_run
8287 .as_ref()
8288 .and_then(|r| {
8289 r.totals_by_language
8290 .iter()
8291 .filter(|l| l.test_count > 0)
8292 .max_by_key(|l| l.test_count)
8293 })
8294 .map_or_else(
8295 || "\u{2014}".to_string(),
8296 |l| l.language.display_name().to_string(),
8297 );
8298 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8299 r.per_file_records
8300 .iter()
8301 .filter(|f| f.raw_line_categories.test_count > 0)
8302 .count() as u64
8303 });
8304 let total_files_analyzed: u64 = latest_run
8305 .as_ref()
8306 .map_or(0, |r| r.summary_totals.files_analyzed);
8307 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8308
8309 let cov_line_pct_str: String = latest_run
8311 .as_ref()
8312 .filter(|r| r.summary_totals.coverage_lines_found > 0)
8313 .map_or_else(
8314 || "0".to_string(),
8315 |r| {
8316 format!(
8317 "{:.1}",
8318 r.summary_totals.coverage_lines_hit as f64
8319 / r.summary_totals.coverage_lines_found as f64
8320 * 100.0
8321 )
8322 },
8323 );
8324 let cov_fn_pct_str: String = latest_run
8325 .as_ref()
8326 .filter(|r| r.summary_totals.coverage_functions_found > 0)
8327 .map_or_else(
8328 || "0".to_string(),
8329 |r| {
8330 format!(
8331 "{:.1}",
8332 r.summary_totals.coverage_functions_hit as f64
8333 / r.summary_totals.coverage_functions_found as f64
8334 * 100.0
8335 )
8336 },
8337 );
8338 let cov_branch_pct_str: String = latest_run
8339 .as_ref()
8340 .filter(|r| r.summary_totals.coverage_branches_found > 0)
8341 .map_or_else(
8342 || "0".to_string(),
8343 |r| {
8344 format!(
8345 "{:.1}",
8346 r.summary_totals.coverage_branches_hit as f64
8347 / r.summary_totals.coverage_branches_found as f64
8348 * 100.0
8349 )
8350 },
8351 );
8352
8353 let cov_no_data_notice = if has_coverage {
8354 String::new()
8355 } else {
8356 String::from(
8357 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8358<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>
8359<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8360 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8361 <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>
8362 <span style="color:var(--muted);font-size:12px;">·</span>
8363 <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>
8364 <span style="color:var(--muted);font-size:12px;">·</span>
8365 <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>
8366</div>
8367<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8368</div>"#,
8369 )
8370 };
8371
8372 let workspace_density_str = format!("{workspace_density:.1}");
8373 let nonce = &csp_nonce;
8374 let version = env!("CARGO_PKG_VERSION");
8375
8376 let watched_dirs_html: String = if state.server_mode {
8379 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()
8380 } else {
8381 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8382 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8383 .to_string()
8384 } else {
8385 watched_dirs_list
8386 .iter()
8387 .fold(String::new(), |mut s, d| {
8388 use std::fmt::Write as _;
8389 let escaped =
8390 d.replace('&', "&").replace('"', """).replace('<', "<");
8391 write!(
8392 s,
8393 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>"#
8394 ).expect("write to String is infallible");
8395 s
8396 })
8397 };
8398 format!(
8399 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>"#
8400 )
8401 };
8402
8403 let scope_data_json: String = {
8405 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8406 scope_map.insert(
8407 "__all__".to_string(),
8408 latest_run.as_ref().map_or_else(
8409 || {
8410 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8411 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
8412 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8413 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8414 "has_coverage":false,"submodules":{}})
8415 },
8416 build_test_scope_entry,
8417 ),
8418 );
8419 let all_roots: Vec<String> = {
8420 let reg = state.registry.lock().await;
8421 let mut seen = std::collections::BTreeSet::new();
8422 reg.entries
8423 .iter()
8424 .flat_map(|e| e.input_roots.iter().cloned())
8425 .filter(|r| seen.insert(r.clone()))
8426 .collect()
8427 };
8428 for root in &all_roots {
8429 let run_for_root: Option<AnalysisRun> = {
8430 let reg = state.registry.lock().await;
8431 let json_str = reg
8432 .entries
8433 .iter()
8434 .find(|e| e.input_roots.iter().any(|r| r == root))
8435 .and_then(|e| e.json_path.as_ref())
8436 .and_then(|p| std::fs::read_to_string(p).ok());
8437 drop(reg);
8438 json_str
8439 .as_deref()
8440 .and_then(|s| serde_json::from_str(s).ok())
8441 };
8442 if let Some(ref run) = run_for_root {
8443 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8444 }
8445 }
8446 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8447 };
8448
8449 let html = format!(
8450 r#"<!doctype html>
8451<html lang="en">
8452<head>
8453 <meta charset="utf-8" />
8454 <meta name="viewport" content="width=device-width, initial-scale=1" />
8455 <title>OxideSLOC | Test Metrics</title>
8456 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8457 <style nonce="{nonce}">
8458 :root {{
8459 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8460 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8461 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8462 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8463 --info-bg:#eef3ff; --info-text:#4467d8;
8464 }}
8465 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8466 *{{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;}}
8467 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8468 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8469 .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;}}
8470 @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));}}}}
8471 .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);}}
8472 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8473 .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));}}
8474 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8475 .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;}}
8476 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8477 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8478 @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; }} }}
8479 .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;}}
8480 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8481 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8482 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8483 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8484 .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;}}
8485 .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;}}
8486 .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;}}
8487 .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;}}
8488 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8489 .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);}}
8490 .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;}}
8491 .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;}}
8492 .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;}}
8493 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8494 .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;}}
8495 .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);}}
8496 .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;}}
8497 .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;}}
8498 .tz-select:focus{{border-color:var(--oxide);}}
8499 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8500 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8501 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8502 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8503 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8504 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8505 .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;}}
8506 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8507 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8508 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8509 .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;}}
8510 .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;}}
8511 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8512 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8513 .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);}}
8514 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8515 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8516 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8517 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8518 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8519 .chart-canvas-wrap{{position:relative;height:280px;}}
8520 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8521 .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;}}
8522 .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;}}
8523 .data-table tr:last-child td{{border-bottom:none;}}
8524 .data-table tbody tr:hover td{{background:var(--surface-2);}}
8525 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8526 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8527 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8528 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8529 .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;}}
8530 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8531 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8532 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8533 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8534 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8535 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8536 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8537 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8538 .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;}}
8539 .chart-select:focus{{border-color:var(--accent);}}
8540 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8541 .trend-canvas-wrap{{position:relative;height:260px;}}
8542 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8543 .site-footer a{{color:var(--muted);}}
8544 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8545 .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;}}
8546 .btn:hover{{background:var(--surface-2);}}
8547 .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;}}
8548 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8549 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8550 .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;}}
8551 .scope-sel:focus{{border-color:var(--accent);}}
8552 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8553 .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;}}
8554 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8555 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8556 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8557 .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;}}
8558 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8559 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8560 .watched-chip-rm:hover{{color:var(--oxide);}}
8561 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8562 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8563 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8564 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8565 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8566 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8567 .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;}}
8568 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8569 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8570 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8571 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8572 .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;}}
8573 .cov-file-search:focus{{border-color:var(--accent);}}
8574 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8575 .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;}}
8576 body.dark-theme .cov-file-search{{background:var(--surface);}}
8577 </style>
8578</head>
8579<body>
8580 <div class="background-watermarks" aria-hidden="true">
8581 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8582 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8583 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8584 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8585 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8586 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8587 </div>
8588 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8589 <div class="top-nav">
8590 <div class="top-nav-inner">
8591 <a class="brand" href="/">
8592 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8593 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8594 </a>
8595 <div class="nav-right">
8596 <a class="nav-pill" href="/">Home</a>
8597 <div class="nav-dropdown">
8598 <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>
8599 <div class="nav-dropdown-menu">
8600 <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>
8601 </div>
8602 </div>
8603 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8604 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8605 <div class="nav-dropdown">
8606 <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>
8607 <div class="nav-dropdown-menu">
8608 <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>
8609 </div>
8610 </div>
8611 <div class="server-status-wrap" id="server-status-wrap">
8612 <div class="nav-pill server-online-pill" id="server-status-pill">
8613 <span class="status-dot" id="status-dot"></span>
8614 <span id="server-status-label">Server</span>
8615 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8616 </div>
8617 <div class="server-status-tip">
8618 OxideSLOC is running — accessible on your network.
8619 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8620 </div>
8621 </div>
8622 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8623 <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>
8624 </button>
8625 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8626 <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>
8627 <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>
8628 </button>
8629 </div>
8630 </div>
8631 </div>
8632
8633 <div class="page">
8634 {watched_dirs_html}
8635 <div class="scope-bar">
8636 <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>
8637 <span class="scope-label">Scope</span>
8638 <div class="scope-sel-wrap">
8639 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8640 <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);">
8641 <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>
8642 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8643 </div>
8644 </div>
8645 </div>
8646 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8647 <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>
8648 <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>
8649 <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>
8650 <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>
8651 </div>
8652 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8653 <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>
8654 <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>
8655 <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>
8656 <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>
8657 </div>
8658
8659 <div class="panel">
8660 <h1>Test Metrics</h1>
8661 <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>
8662
8663 <div class="chart-row">
8664 <div class="chart-box">
8665 <div class="chart-box-title">Test Definitions by Language</div>
8666 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8667 </div>
8668 <div class="chart-box">
8669 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8670 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8671 </div>
8672 </div>
8673
8674 <div class="section-header">Language Breakdown</div>
8675 {cov_no_data_notice}
8676 <div style="overflow-x:auto;">
8677 <table class="data-table" id="lang-table">
8678 <thead><tr>
8679 <th>Language</th>
8680 <th class="num">Test Fns</th>
8681 <th class="num">Assertions</th>
8682 <th class="num">Suites</th>
8683 <th class="num">Code Lines</th>
8684 <th class="num">Files</th>
8685 <th class="num">Density / 1K</th>
8686 <th>Relative Density</th>
8687 </tr></thead>
8688 <tbody id="lang-tbody"></tbody>
8689 </table>
8690 </div>
8691 </div>
8692
8693 <div class="panel" id="cov-panel" style="display:none;">
8694 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8695 <div class="cov-gauge-row" id="cov-gauges">
8696 <div class="cov-gauge-card">
8697 <div class="cov-gauge-label">Line Coverage</div>
8698 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8699 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8700 <div class="cov-gauge-sub">Lines hit / instrumented</div>
8701 </div>
8702 <div class="cov-gauge-card">
8703 <div class="cov-gauge-label">Function Coverage</div>
8704 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8705 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8706 <div class="cov-gauge-sub">Functions hit / found</div>
8707 </div>
8708 <div class="cov-gauge-card">
8709 <div class="cov-gauge-label">Branch Coverage</div>
8710 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8711 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8712 <div class="cov-gauge-sub">Branches hit / found</div>
8713 </div>
8714 </div>
8715 <div class="chart-row">
8716 <div class="chart-box">
8717 <div class="chart-box-title">Line Coverage % by Language</div>
8718 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8719 </div>
8720 <div class="chart-box">
8721 <div class="chart-box-title">Coverage Tier Distribution</div>
8722 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8723 </div>
8724 </div>
8725
8726 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8727 <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>
8728 <div class="cov-file-toolbar">
8729 <div class="cov-filter-tabs" id="cov-filter-tabs">
8730 <button class="cov-tab active" data-tier="all">All</button>
8731 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8732 <button class="cov-tab" data-tier="low">Low (<50%)</button>
8733 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8734 <button class="cov-tab" data-tier="high">High (≥80%)</button>
8735 </div>
8736 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8737 </div>
8738 <div style="overflow-x:auto;">
8739 <table class="data-table" id="cov-file-table">
8740 <thead><tr>
8741 <th>File</th>
8742 <th>Lang</th>
8743 <th class="num">Line %</th>
8744 <th class="num">Lines Hit / Found</th>
8745 <th class="num">Fn %</th>
8746 <th class="num">Fns Hit / Found</th>
8747 </tr></thead>
8748 <tbody id="cov-file-tbody"></tbody>
8749 </table>
8750 </div>
8751 <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>
8752 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
8753 </div>
8754
8755 <div class="panel">
8756 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
8757 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
8758 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
8759 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
8760 </div>
8761 </div>
8762
8763 <footer class="site-footer">
8764 local code analysis - metrics, history and reports
8765 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
8766 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8767 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8768 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8769 · <a href="/api-docs" rel="noopener">REST API</a>
8770 </footer>
8771
8772 <script nonce="{nonce}">
8773 (function() {{
8774 // Theme
8775 var b = document.body;
8776 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8777 var tgl = document.getElementById('theme-toggle');
8778 if (tgl) tgl.addEventListener('click', function() {{
8779 var d = b.classList.toggle('dark-theme');
8780 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8781 }});
8782
8783 // Watermarks
8784 (function() {{
8785 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8786 if (!wms.length) return;
8787 var placed = [];
8788 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;}}
8789 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];}}
8790 var half=Math.floor(wms.length/2);
8791 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;}});
8792 }})();
8793
8794 // Code particles
8795 (function() {{
8796 var container = document.getElementById('code-particles');
8797 if (!container) return;
8798 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
8799 for (var i = 0; i < 36; i++) {{
8800 (function(idx) {{
8801 var el = document.createElement('span');
8802 el.className = 'code-particle';
8803 el.textContent = snippets[idx % snippets.length];
8804 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8805 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8806 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8807 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';
8808 container.appendChild(el);
8809 }})(i);
8810 }}
8811 }})();
8812
8813 // Settings modal
8814 (function() {{
8815 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'}}];
8816 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);}});}}
8817 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8818 var btn=document.getElementById('settings-btn');if(!btn)return;
8819 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8820 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>';
8821 document.body.appendChild(m);
8822 var g=document.getElementById('scheme-grid');
8823 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);}});
8824 var cl=document.getElementById('settings-close');
8825 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');}});
8826 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8827 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8828 }})();
8829
8830 // Watched folder picker
8831 (function() {{
8832 var btn = document.getElementById('add-watched-btn');
8833 if (!btn) return;
8834 btn.addEventListener('click', function() {{
8835 fetch('/pick-directory?kind=reports')
8836 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8837 .then(function(data) {{
8838 if (!data.cancelled && data.selected_path) {{
8839 var form = document.createElement('form');
8840 form.method = 'POST';
8841 form.action = '/watched-dirs/add';
8842 var ri = document.createElement('input');
8843 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8844 var fi = document.createElement('input');
8845 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8846 form.appendChild(ri); form.appendChild(fi);
8847 document.body.appendChild(form);
8848 form.submit();
8849 }}
8850 }})
8851 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8852 }});
8853 }})();
8854 }})();
8855 </script>
8856
8857 <script src="/static/chart.js" nonce="{nonce}"></script>
8858 <script nonce="{nonce}">
8859 (function() {{
8860 var SCOPE_DATA = {scope_data_json};
8861 var currentRoot = '__all__';
8862 var currentSub = '';
8863 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
8864 var ALL_CHARTS = [];
8865
8866 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();}}
8867 function fmtFull(n){{return Number(n).toLocaleString();}}
8868 function isDark(){{return document.body.classList.contains('dark-theme');}}
8869 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
8870 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
8871 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
8872
8873 function getDataset() {{
8874 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8875 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
8876 return r;
8877 }}
8878 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
8879
8880 function renderTestCharts(D) {{
8881 testsChart = destroyChart(testsChart);
8882 densityChart = destroyChart(densityChart);
8883 if (!D || !D.length) return;
8884 var top15 = D.slice(0, 15);
8885 var canvas1 = document.getElementById('canvas-tests');
8886 if (canvas1) {{
8887 testsChart = new Chart(canvas1, {{
8888 type: 'bar',
8889 data: {{
8890 labels: top15.map(function(d){{ return d.lang; }}),
8891 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
8892 }},
8893 options: {{
8894 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8895 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
8896 scales: {{
8897 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
8898 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8899 }}
8900 }}
8901 }});
8902 ALL_CHARTS.push(testsChart);
8903 }}
8904 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
8905 var canvas2 = document.getElementById('canvas-density');
8906 if (canvas2) {{
8907 densityChart = new Chart(canvas2, {{
8908 type: 'bar',
8909 data: {{
8910 labels: topD.map(function(d){{ return d.lang; }}),
8911 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 }}]
8912 }},
8913 options: {{
8914 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8915 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
8916 scales: {{
8917 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
8918 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8919 }}
8920 }}
8921 }});
8922 ALL_CHARTS.push(densityChart);
8923 }}
8924 }}
8925
8926 function renderCovCharts(covD, tiers) {{
8927 covChart = destroyChart(covChart);
8928 tierChart = destroyChart(tierChart);
8929 var covCanvas = document.getElementById('canvas-cov');
8930 if (covCanvas && covD && covD.length) {{
8931 covChart = new Chart(covCanvas, {{
8932 type: 'bar',
8933 data: {{
8934 labels: covD.map(function(d){{ return d.lang; }}),
8935 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 }}]
8936 }},
8937 options: {{
8938 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8939 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
8940 scales: {{
8941 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
8942 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8943 }}
8944 }}
8945 }});
8946 ALL_CHARTS.push(covChart);
8947 }}
8948 var tierCanvas = document.getElementById('canvas-cov-tiers');
8949 if (tierCanvas && tiers) {{
8950 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
8951 tierChart = new Chart(tierCanvas, {{
8952 type: 'doughnut',
8953 data: {{
8954 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
8955 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
8956 }},
8957 options: {{
8958 responsive: true, maintainAspectRatio: false, cutout: '62%',
8959 plugins: {{
8960 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
8961 tooltip: {{ callbacks: {{ label: function(ctx) {{
8962 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
8963 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
8964 }} }} }}
8965 }}
8966 }}
8967 }});
8968 ALL_CHARTS.push(tierChart);
8969 }}
8970 }}
8971
8972 function buildLangTable(D) {{
8973 var tbody = document.getElementById('lang-tbody');
8974 if (!tbody) return;
8975 if (!D || !D.length) {{
8976 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>';
8977 return;
8978 }}
8979 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
8980 tbody.innerHTML = D.map(function(d) {{
8981 var barW = Math.round(d.density / maxDensity * 120);
8982 return '<tr>' +
8983 '<td><strong>' + d.lang + '</strong></td>' +
8984 '<td class="num">' + fmt(d.tests) + '</td>' +
8985 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
8986 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
8987 '<td class="num">' + fmt(d.code) + '</td>' +
8988 '<td class="num">' + fmt(d.files) + '</td>' +
8989 '<td class="num">' + d.density.toFixed(2) + '</td>' +
8990 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
8991 '</tr>';
8992 }}).join('');
8993 }}
8994
8995 var covFileData = [];
8996 var covFileTier = 'all';
8997 var covFileSearch = '';
8998
8999 function pctBadge(pct) {{
9000 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
9001 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
9002 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
9003 }}
9004
9005 function buildCovFileTable() {{
9006 var tbody = document.getElementById('cov-file-tbody');
9007 var empty = document.getElementById('cov-file-empty');
9008 var count = document.getElementById('cov-file-count');
9009 if (!tbody) return;
9010 var srch = covFileSearch.toLowerCase();
9011 var filtered = covFileData.filter(function(f) {{
9012 if (covFileTier === 'zero' && f.line_pct > 0) return false;
9013 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
9014 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
9015 if (covFileTier === 'high' && f.line_pct < 80) return false;
9016 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
9017 return true;
9018 }});
9019 if (!filtered.length) {{
9020 tbody.innerHTML = '';
9021 if (empty) empty.style.display = '';
9022 if (count) count.textContent = '';
9023 return;
9024 }}
9025 if (empty) empty.style.display = 'none';
9026 var shown = Math.min(filtered.length, 500);
9027 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
9028 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
9029 var fnCol = f.fn_pct < 0
9030 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
9031 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
9032 return '<tr>' +
9033 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
9034 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
9035 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9036 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9037 fnCol +
9038 '</tr>';
9039 }}).join('');
9040 }}
9041
9042 (function() {{
9043 var tabs = document.getElementById('cov-filter-tabs');
9044 if (tabs) {{
9045 tabs.addEventListener('click', function(e) {{
9046 var btn = e.target.closest('.cov-tab');
9047 if (!btn) return;
9048 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9049 btn.classList.add('active');
9050 covFileTier = btn.getAttribute('data-tier');
9051 buildCovFileTable();
9052 }});
9053 }}
9054 var srch = document.getElementById('cov-file-search');
9055 if (srch) {{
9056 srch.addEventListener('input', function() {{
9057 covFileSearch = this.value;
9058 buildCovFileTable();
9059 }});
9060 }}
9061 }})();
9062
9063 function updateCovGauges(t) {{
9064 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9065 var el;
9066 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9067 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9068 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9069 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9070 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9071 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9072 }}
9073
9074 function applyScope() {{
9075 var d = getDataset();
9076 var t = d.totals;
9077 var el;
9078 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9079 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9080 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9081 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9082 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9083 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9084 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9085 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9086 renderTestCharts(d.lang_tests);
9087 buildLangTable(d.lang_tests);
9088 var covPanel = document.getElementById('cov-panel');
9089 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9090 if (d.has_coverage) {{
9091 renderCovCharts(d.cov, d.cov_tiers);
9092 updateCovGauges(t);
9093 covFileData = d.file_cov || [];
9094 covFileTier = 'all';
9095 covFileSearch = '';
9096 var tabs = document.getElementById('cov-filter-tabs');
9097 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9098 var srch = document.getElementById('cov-file-search');
9099 if (srch) srch.value = '';
9100 buildCovFileTable();
9101 }}
9102 loadTrend();
9103 }}
9104
9105 // Populate scope-root-sel from SCOPE_DATA keys
9106 (function() {{
9107 var sel = document.getElementById('scope-root-sel');
9108 if (!sel) return;
9109 Object.keys(SCOPE_DATA).forEach(function(k) {{
9110 if (k === '__all__') return;
9111 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9112 }});
9113 }})();
9114
9115 document.getElementById('scope-root-sel').addEventListener('change', function() {{
9116 currentRoot = this.value;
9117 currentSub = '';
9118 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9119 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9120 var subWrap = document.getElementById('scope-sub-wrap');
9121 var subSel = document.getElementById('scope-sub-sel');
9122 subSel.innerHTML = '<option value="">Entire project</option>';
9123 if (subNames.length) {{
9124 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9125 subWrap.style.display = 'flex';
9126 }} else {{
9127 subWrap.style.display = 'none';
9128 }}
9129 applyScope();
9130 }});
9131
9132 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9133 currentSub = this.value;
9134 applyScope();
9135 }});
9136
9137 function buildTrend(data) {{
9138 var trendCanvas = document.getElementById('canvas-trend');
9139 var trendEmpty = document.getElementById('trend-empty');
9140 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9141 pts = pts.slice().reverse();
9142 if (!pts.length) {{
9143 if (trendCanvas) trendCanvas.style.display = 'none';
9144 if (trendEmpty) trendEmpty.style.display = '';
9145 return;
9146 }}
9147 if (trendCanvas) trendCanvas.style.display = '';
9148 if (trendEmpty) trendEmpty.style.display = 'none';
9149 trendChart = destroyChart(trendChart);
9150 if (!trendCanvas) return;
9151 trendChart = new Chart(trendCanvas, {{
9152 type: 'line',
9153 data: {{
9154 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9155 datasets: [{{
9156 label: 'Test Definitions',
9157 data: pts.map(function(d){{ return d.test_count; }}),
9158 borderColor: '#C45C10',
9159 backgroundColor: 'rgba(196,92,16,0.10)',
9160 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9161 pointRadius: 5, fill: true, tension: 0.3
9162 }}]
9163 }},
9164 options: {{
9165 responsive: true, maintainAspectRatio: false,
9166 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9167 scales: {{
9168 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9169 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9170 }}
9171 }}
9172 }});
9173 ALL_CHARTS.push(trendChart);
9174 }}
9175
9176 function loadTrend() {{
9177 var url = '/api/metrics/history?limit=100';
9178 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9179 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9180 buildTrend(data);
9181 }}).catch(function(){{
9182 var trendEmpty = document.getElementById('trend-empty');
9183 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9184 }});
9185 }}
9186
9187 // Re-render charts on theme toggle
9188 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9189 setTimeout(function() {{
9190 ALL_CHARTS.forEach(function(c) {{
9191 if (c && c.options && c.options.scales) {{
9192 Object.values(c.options.scales).forEach(function(ax) {{
9193 if (ax.grid) ax.grid.color = clr();
9194 if (ax.ticks) ax.ticks.color = txtClr();
9195 }});
9196 c.update();
9197 }}
9198 }});
9199 }}, 80);
9200 }});
9201
9202 applyScope();
9203 }})();
9204 </script>
9205 <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>
9206</body>
9207</html>"#,
9208 );
9209 Html(html).into_response()
9210}
9211
9212#[derive(Deserialize)]
9219struct EmbedQuery {
9220 run_id: Option<String>,
9221 theme: Option<String>,
9222}
9223
9224async fn embed_handler(
9225 State(state): State<AppState>,
9226 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9227 Query(query): Query<EmbedQuery>,
9228) -> Response {
9229 let entry = {
9230 let reg = state.registry.lock().await;
9231 query.run_id.as_ref().map_or_else(
9232 || reg.entries.first().cloned(),
9233 |id| reg.find_by_run_id(id).cloned(),
9234 )
9235 };
9236
9237 let Some(entry) = entry else {
9238 return Html(
9239 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9240 .to_string(),
9241 )
9242 .into_response();
9243 };
9244
9245 let dark = query.theme.as_deref() == Some("dark");
9246 let languages: Vec<(String, u64, u64)> = entry
9247 .json_path
9248 .as_ref()
9249 .and_then(|p| read_json(p).ok())
9250 .map(|run| {
9251 run.totals_by_language
9252 .iter()
9253 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9254 .collect()
9255 })
9256 .unwrap_or_default();
9257
9258 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9259}
9260
9261fn render_embed_widget(
9262 entry: &RegistryEntry,
9263 languages: &[(String, u64, u64)],
9264 dark: bool,
9265 csp_nonce: &str,
9266) -> String {
9267 let s = &entry.summary;
9268 let total = s.code_lines + s.comment_lines + s.blank_lines;
9269 let code_pct = s
9270 .code_lines
9271 .checked_mul(100)
9272 .and_then(|n| n.checked_div(total))
9273 .unwrap_or(0);
9274
9275 let (bg, fg, surface, muted, border) = if dark {
9276 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9277 } else {
9278 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9279 };
9280
9281 let mut lang_rows = String::new();
9282 for (name, files, code) in languages {
9283 write!(
9284 lang_rows,
9285 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9286 escape_html(name),
9287 format_number(*files),
9288 format_number(*code),
9289 )
9290 .ok();
9291 }
9292
9293 let lang_table = if lang_rows.is_empty() {
9294 String::new()
9295 } else {
9296 format!(
9297 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9298 )
9299 };
9300
9301 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9302 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9303 let project_esc = escape_html(&entry.project_label);
9304 let code_lines = format_number(s.code_lines);
9305 let comment_lines = format_number(s.comment_lines);
9306 let files = format_number(s.files_analyzed);
9307 let code_raw = s.code_lines;
9308 let comment_raw = s.comment_lines;
9309 let blank_raw = s.blank_lines;
9310
9311 format!(
9312 r#"<!doctype html>
9313<html lang="en">
9314<head>
9315 <meta charset="utf-8">
9316 <meta name="viewport" content="width=device-width,initial-scale=1">
9317 <title>OxideSLOC — {project_esc}</title>
9318 <script src="/static/chart.js"></script>
9319 <style nonce="{csp_nonce}">
9320 *{{box-sizing:border-box;margin:0;padding:0}}
9321 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9322 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9323 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9324 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9325 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9326 .card .v{{font-size:18px;font-weight:700}}
9327 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9328 .row{{display:flex;gap:12px;align-items:flex-start}}
9329 .pie{{width:120px;height:120px;flex-shrink:0}}
9330 .lt{{border-collapse:collapse;width:100%;flex:1}}
9331 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9332 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9333 .n{{text-align:right}}
9334 .footer{{margin-top:10px;color:{muted};font-size:10px}}
9335 </style>
9336</head>
9337<body>
9338 <h2>{project_esc}</h2>
9339 <div class="sub">{timestamp} · run {run_short}</div>
9340 <div class="cards">
9341 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9342 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9343 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9344 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9345 </div>
9346 <div class="row">
9347 <canvas class="pie" id="c"></canvas>
9348 {lang_table}
9349 </div>
9350 <div class="footer">oxide-sloc</div>
9351 <script nonce="{csp_nonce}">
9352 new Chart(document.getElementById('c'),{{
9353 type:'doughnut',
9354 data:{{
9355 labels:['Code','Comments','Blank'],
9356 datasets:[{{
9357 data:[{code_raw},{comment_raw},{blank_raw}],
9358 backgroundColor:['#4a78ee','#b35428','#aaa'],
9359 borderWidth:0
9360 }}]
9361 }},
9362 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9363 }});
9364 </script>
9365</body>
9366</html>"#
9367 )
9368}
9369
9370#[allow(clippy::too_many_arguments)]
9371fn persist_run_artifacts(
9372 run: &sloc_core::AnalysisRun,
9373 report_html: &str,
9374 run_dir: &Path,
9375 generate_json: bool,
9376 generate_html: bool,
9377 generate_pdf: bool,
9378 report_title: &str,
9379 file_stem: &str,
9380 result_context: RunResultContext,
9381) -> Result<(RunArtifacts, PendingPdf)> {
9382 fs::create_dir_all(run_dir)
9383 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9384
9385 let mut html_path = None;
9386 let mut pdf_path = None;
9387 let mut json_path = None;
9388 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9389
9390 if generate_html {
9391 let path = run_dir.join(format!("report_{file_stem}.html"));
9392 fs::write(&path, report_html)
9393 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9394 html_path = Some(path);
9395 }
9396
9397 if generate_json {
9398 let path = run_dir.join(format!("result_{file_stem}.json"));
9399 let json = serde_json::to_string_pretty(run)
9400 .context("failed to serialize analysis run to JSON")?;
9401 fs::write(&path, json)
9402 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9403 json_path = Some(path);
9404 }
9405
9406 if generate_pdf {
9407 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9408
9409 match write_pdf_from_run(run, &pdf_dest) {
9412 Ok(()) => {
9413 eprintln!(
9414 "[oxide-sloc][pdf] native PDF written to {}",
9415 pdf_dest.display()
9416 );
9417 pdf_path = Some(pdf_dest);
9418 }
9420 Err(native_err) => {
9421 eprintln!(
9422 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9423 scheduling HTML→browser fallback"
9424 );
9425 let source_html_path = if let Some(existing) = html_path.as_ref() {
9426 existing.clone()
9427 } else {
9428 let temp_html = run_dir.join("_report_rendered.html");
9429 fs::write(&temp_html, report_html).with_context(|| {
9430 format!(
9431 "failed to write temporary HTML report to {}",
9432 temp_html.display()
9433 )
9434 })?;
9435 temp_html
9436 };
9437 let cleanup_src = !generate_html;
9438 pdf_path = Some(pdf_dest.clone());
9439 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9440 }
9441 }
9442 }
9443
9444 let csv_path = {
9446 let path = run_dir.join(format!("report_{file_stem}.csv"));
9447 if let Err(e) = sloc_report::write_csv(run, &path) {
9448 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9449 None
9450 } else {
9451 Some(path)
9452 }
9453 };
9454
9455 let xlsx_path = {
9456 let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9457 if let Err(e) = sloc_report::write_xlsx(run, &path) {
9458 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9459 None
9460 } else {
9461 Some(path)
9462 }
9463 };
9464
9465 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9466
9467 Ok((
9468 RunArtifacts {
9469 output_dir: run_dir.to_path_buf(),
9470 html_path,
9471 pdf_path,
9472 json_path,
9473 csv_path,
9474 xlsx_path,
9475 scan_config_path,
9476 report_title: report_title.to_string(),
9477 result_context,
9478 },
9479 pending_pdf,
9480 ))
9481}
9482
9483fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9486 let exact = dir.join("scan-config.json");
9487 if exact.exists() {
9488 return Some(exact);
9489 }
9490 fs::read_dir(dir).ok().and_then(|entries| {
9491 entries
9492 .filter_map(std::result::Result::ok)
9493 .find(|e| {
9494 let name = e.file_name();
9495 let name = name.to_string_lossy();
9496 name.starts_with("scan-config") && name.ends_with(".json")
9497 })
9498 .map(|e| e.path())
9499 })
9500}
9501
9502async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9505 let toml_str = match toml::to_string_pretty(&state.base_config) {
9506 Ok(s) => s,
9507 Err(e) => {
9508 return (
9509 StatusCode::INTERNAL_SERVER_ERROR,
9510 format!("serialization error: {e}"),
9511 )
9512 .into_response();
9513 }
9514 };
9515 (
9516 [
9517 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9518 (
9519 header::CONTENT_DISPOSITION,
9520 "attachment; filename=\".oxide-sloc.toml\"",
9521 ),
9522 ],
9523 toml_str,
9524 )
9525 .into_response()
9526}
9527
9528#[derive(Serialize)]
9529struct OkResponse {
9530 ok: bool,
9531}
9532
9533#[derive(Serialize)]
9534struct SaveProfileResponse {
9535 ok: bool,
9536 id: String,
9537}
9538
9539#[derive(Serialize)]
9540struct ProfileListResponse {
9541 profiles: Vec<ScanProfile>,
9542}
9543
9544#[derive(Serialize)]
9545struct ImportConfigResponse {
9546 ok: bool,
9547 config: sloc_config::AppConfig,
9548}
9549
9550#[derive(Deserialize)]
9551struct ImportConfigBody {
9552 toml: String,
9553}
9554
9555async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9556 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9557 Ok(config) => {
9558 if let Err(e) = config.validate() {
9559 return error::unprocessable_entity(&e.to_string());
9560 }
9561 Json(ImportConfigResponse { ok: true, config }).into_response()
9562 }
9563 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9564 }
9565}
9566
9567async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9570 let store = state.scan_profiles.lock().await;
9571 Json(ProfileListResponse {
9572 profiles: store.profiles.clone(),
9573 })
9574}
9575
9576#[derive(Deserialize)]
9577struct SaveScanProfileBody {
9578 name: String,
9579 params: serde_json::Value,
9580}
9581
9582async fn api_save_scan_profile(
9583 State(state): State<AppState>,
9584 Json(body): Json<SaveScanProfileBody>,
9585) -> impl IntoResponse {
9586 if body.name.trim().is_empty() {
9587 return error::bad_request("name must not be empty");
9588 }
9589
9590 let id = uuid::Uuid::new_v4().to_string();
9591 let profile = ScanProfile {
9592 id: id.clone(),
9593 name: body.name.trim().to_string(),
9594 created_at: chrono::Utc::now().to_rfc3339(),
9595 params: body.params,
9596 };
9597
9598 let mut store = state.scan_profiles.lock().await;
9599 store.profiles.push(profile);
9600 if let Err(e) = store.save(&state.scan_profiles_path) {
9601 tracing::warn!("failed to persist scan profiles: {e}");
9602 }
9603 drop(store);
9604
9605 (
9606 StatusCode::CREATED,
9607 Json(SaveProfileResponse { ok: true, id }),
9608 )
9609 .into_response()
9610}
9611
9612async fn api_delete_scan_profile(
9613 State(state): State<AppState>,
9614 AxumPath(id): AxumPath<String>,
9615) -> impl IntoResponse {
9616 let mut store = state.scan_profiles.lock().await;
9617 let before = store.profiles.len();
9618 store.profiles.retain(|p| p.id != id);
9619 if store.profiles.len() == before {
9620 drop(store);
9621 return error::not_found("profile not found");
9622 }
9623 if let Err(e) = store.save(&state.scan_profiles_path) {
9624 tracing::warn!("failed to persist scan profiles: {e}");
9625 }
9626 drop(store);
9627 Json(OkResponse { ok: true }).into_response()
9628}
9629
9630fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9631 let value = raw.unwrap_or("out/web").trim();
9632 let path = if value.is_empty() {
9633 PathBuf::from("out/web")
9634 } else {
9635 PathBuf::from(value)
9636 };
9637
9638 if path.is_absolute() {
9639 path
9640 } else {
9641 workspace_root().join(path)
9642 }
9643}
9644
9645fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9647 std::env::var("SLOC_GIT_CLONES_DIR")
9648 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9649}
9650
9651pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9654 let safe: String = repo_url
9655 .chars()
9656 .map(|c| {
9657 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9658 c
9659 } else {
9660 '_'
9661 }
9662 })
9663 .take(80)
9664 .collect();
9665 clones_dir.join(safe)
9666}
9667
9668pub(crate) fn scan_path_to_artifacts(
9671 scan_path: &Path,
9672 base_config: &AppConfig,
9673 label: &str,
9674) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9675 let mut config = base_config.clone();
9676 config.discovery.root_paths = vec![scan_path.to_path_buf()];
9677 label.clone_into(&mut config.reporting.report_title);
9678 let run = analyze(&config, "git", None)?;
9679 let html = render_html(&run)?;
9680 let run_id = run.tool.run_id.clone();
9681 let project_label = sanitize_project_label(label);
9682 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9683 let file_stem = {
9684 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9685 if commit.is_empty() {
9686 project_label
9687 } else {
9688 format!("{project_label}_{commit}")
9689 }
9690 };
9691 let (artifacts, _pending_pdf) = persist_run_artifacts(
9692 &run,
9693 &html,
9694 &output_dir,
9695 true,
9696 true,
9697 false,
9698 label,
9699 &file_stem,
9700 RunResultContext::default(),
9701 )?;
9702 Ok((run_id, artifacts, run))
9703}
9704
9705async fn restart_poll_schedules(state: &AppState) {
9707 let store = state.schedules.lock().await;
9708 let poll_schedules: Vec<_> = store
9709 .schedules
9710 .iter()
9711 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9712 .cloned()
9713 .collect();
9714 drop(store);
9715 for schedule in poll_schedules {
9716 let interval = schedule.interval_secs.unwrap_or(300);
9717 let st = state.clone();
9718 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9719 }
9720}
9721
9722fn split_patterns(raw: Option<&str>) -> Vec<String> {
9723 raw.unwrap_or("")
9724 .lines()
9725 .flat_map(|line| line.split(','))
9726 .map(str::trim)
9727 .filter(|part| !part.is_empty())
9728 .map(ToOwned::to_owned)
9729 .collect()
9730}
9731
9732fn build_sub_run(
9733 parent: &AnalysisRun,
9734 sub: &sloc_core::SubmoduleSummary,
9735 parent_path: &str,
9736) -> AnalysisRun {
9737 let sub_files: Vec<_> = parent
9738 .per_file_records
9739 .iter()
9740 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9741 .cloned()
9742 .collect();
9743 let mut config = parent.effective_configuration.clone();
9744 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
9745 AnalysisRun {
9746 tool: parent.tool.clone(),
9747 environment: parent.environment.clone(),
9748 effective_configuration: config,
9749 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
9750 summary_totals: SummaryTotals {
9751 files_considered: sub.files_analyzed,
9752 files_analyzed: sub.files_analyzed,
9753 files_skipped: 0,
9754 total_physical_lines: sub.total_physical_lines,
9755 code_lines: sub.code_lines,
9756 comment_lines: sub.comment_lines,
9757 blank_lines: sub.blank_lines,
9758 mixed_lines_separate: 0,
9759 functions: 0,
9760 classes: 0,
9761 variables: 0,
9762 imports: 0,
9763 test_count: 0,
9764 test_assertion_count: 0,
9765 test_suite_count: 0,
9766 coverage_lines_found: 0,
9767 coverage_lines_hit: 0,
9768 coverage_functions_found: 0,
9769 coverage_functions_hit: 0,
9770 coverage_branches_found: 0,
9771 coverage_branches_hit: 0,
9772 },
9773 totals_by_language: sub.language_summaries.clone(),
9774 per_file_records: sub_files,
9775 skipped_file_records: vec![],
9776 warnings: vec![],
9777 submodule_summaries: vec![],
9778 git_commit_short: parent.git_commit_short.clone(),
9779 git_commit_long: parent.git_commit_long.clone(),
9780 git_branch: parent.git_branch.clone(),
9781 git_commit_author: parent.git_commit_author.clone(),
9782 git_commit_date: parent.git_commit_date.clone(),
9783 git_tags: parent.git_tags.clone(),
9784 git_nearest_tag: parent.git_nearest_tag.clone(),
9785 git_remote_url: parent.git_remote_url.clone(),
9786 }
9787}
9788
9789pub(crate) fn sanitize_project_label(raw: &str) -> String {
9790 let candidate = Path::new(raw)
9791 .file_name()
9792 .and_then(|name| name.to_str())
9793 .unwrap_or("project");
9794
9795 let mut value = String::with_capacity(candidate.len());
9796 for ch in candidate.chars() {
9797 if ch.is_ascii_alphanumeric() {
9798 value.push(ch.to_ascii_lowercase());
9799 } else {
9800 value.push('-');
9801 }
9802 }
9803
9804 let compact = value.trim_matches('-').to_string();
9805 if compact.is_empty() {
9806 "project".to_string()
9807 } else {
9808 compact
9809 }
9810}
9811
9812fn strip_unc_prefix(path: PathBuf) -> PathBuf {
9815 let s = path.to_string_lossy();
9816 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9817 return PathBuf::from(format!(r"\\{rest}"));
9818 }
9819 if let Some(rest) = s.strip_prefix(r"\\?\") {
9820 return PathBuf::from(rest);
9821 }
9822 path
9823}
9824
9825fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
9828 let base = if let Some(rest) = remote.strip_prefix("git@") {
9829 let (host, path) = rest.split_once(':')?;
9830 format!("https://{}/{}", host, path.trim_end_matches(".git"))
9831 } else if remote.starts_with("https://") || remote.starts_with("http://") {
9832 remote
9833 .trim_end_matches('/')
9834 .trim_end_matches(".git")
9835 .to_owned()
9836 } else {
9837 return None;
9838 };
9839 let base = base.trim_end_matches('/');
9840 if base.contains("gitlab.com") || base.contains("gitlab.") {
9842 Some(format!("{}/-/commit/{}", base, sha))
9843 } else if base.contains("bitbucket.org") {
9844 Some(format!("{}/commits/{}", base, sha))
9845 } else {
9846 Some(format!("{}/commit/{}", base, sha))
9847 }
9848}
9849
9850fn display_path(path: &Path) -> String {
9851 let s = path.to_string_lossy();
9852 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9857 return format!(r"\\{rest}");
9858 }
9859 if let Some(rest) = s.strip_prefix(r"\\?\") {
9860 return rest.to_owned();
9861 }
9862 s.into_owned()
9863}
9864
9865fn sanitize_path_str(s: &str) -> String {
9866 if let Some(rest) = s.strip_prefix("//?/UNC/") {
9870 return format!("//{rest}");
9871 }
9872 if let Some(rest) = s.strip_prefix("//?/") {
9873 return rest.to_owned();
9874 }
9875 display_path(Path::new(s))
9876}
9877
9878fn workspace_root() -> PathBuf {
9879 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
9881 let p = PathBuf::from(root);
9882 if p.is_dir() {
9883 return p;
9884 }
9885 }
9886
9887 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
9890}
9891
9892fn make_git_label(repo: &str, ref_name: &str) -> String {
9894 if repo.is_empty() || ref_name.is_empty() {
9895 return String::new();
9896 }
9897 let base = repo
9898 .trim_end_matches('/')
9899 .trim_end_matches(".git")
9900 .rsplit('/')
9901 .next()
9902 .unwrap_or("repo");
9903 let ref_safe: String = ref_name
9904 .chars()
9905 .map(|c| {
9906 if c.is_alphanumeric() || c == '-' || c == '.' {
9907 c
9908 } else {
9909 '_'
9910 }
9911 })
9912 .collect();
9913 format!("{base}_at_{ref_safe}_sloc")
9914}
9915
9916fn desktop_dir() -> PathBuf {
9918 if let Ok(profile) = std::env::var("USERPROFILE") {
9919 let p = PathBuf::from(profile).join("Desktop");
9920 if p.exists() {
9921 return p;
9922 }
9923 }
9924 if let Ok(home) = std::env::var("HOME") {
9925 let p = PathBuf::from(home).join("Desktop");
9926 if p.exists() {
9927 return p;
9928 }
9929 }
9930 workspace_root().join("out").join("web")
9931}
9932
9933fn resolve_input_path(raw: &str) -> PathBuf {
9934 let trimmed = raw.trim();
9935 if trimmed.is_empty() {
9936 return workspace_root().join("samples").join("basic");
9937 }
9938
9939 let candidate = PathBuf::from(trimmed);
9940 let resolved = if candidate.is_absolute() {
9941 candidate
9942 } else {
9943 let rooted = workspace_root().join(&candidate);
9944 if rooted.exists() {
9945 rooted
9946 } else {
9947 workspace_root().join(candidate)
9948 }
9949 };
9950
9951 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
9954 PathBuf::from(display_path(&canonical))
9955}
9956
9957fn dir_size_bytes(path: &Path) -> u64 {
9958 let mut total = 0u64;
9959 if let Ok(rd) = fs::read_dir(path) {
9960 for entry in rd.filter_map(Result::ok) {
9961 let p = entry.path();
9962 if p.is_file() {
9963 if let Ok(meta) = p.metadata() {
9964 total += meta.len();
9965 }
9966 } else if p.is_dir() {
9967 total += dir_size_bytes(&p);
9968 }
9969 }
9970 }
9971 total
9972}
9973
9974#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
9976 if bytes >= 1_073_741_824 {
9977 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
9978 } else if bytes >= 1_048_576 {
9979 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
9980 } else if bytes >= 1_024 {
9981 format!("{:.0} KB", bytes as f64 / 1_024.0)
9982 } else {
9983 format!("{bytes} B")
9984 }
9985}
9986
9987fn render_submodule_chips(
9988 root: &Path,
9989 submodules: &[(String, std::path::PathBuf)],
9990 out: &mut String,
9991) {
9992 use std::fmt::Write as _;
9993 let count = submodules.len();
9994 out.push_str(r#"<div class="submodule-preview-strip">"#);
9995 write!(
9996 out,
9997 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>"#,
9998 if count == 1 { "" } else { "s" }
9999 )
10000 .ok();
10001 out.push_str(r#"<div class="submodule-preview-chips">"#);
10002 for (sub_name, sub_rel_path) in submodules {
10003 let sub_abs = root.join(sub_rel_path);
10004 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10005 let mut sub_stats = PreviewStats::default();
10006 let mut sub_rows: Vec<PreviewRow> = Vec::new();
10007 let mut sub_langs: Vec<&'static str> = Vec::new();
10008 let mut sub_budget = PreviewBudget {
10009 shown: 0,
10010 max_entries: 2000,
10011 max_depth: 9,
10012 };
10013 let mut sub_next_id = 1usize;
10014 let _ = collect_preview_rows(
10015 &sub_abs,
10016 &sub_abs,
10017 0,
10018 None,
10019 &mut sub_next_id,
10020 &mut sub_budget,
10021 &mut sub_stats,
10022 &mut sub_rows,
10023 &mut sub_langs,
10024 &[],
10025 &[],
10026 );
10027 let stats_json = format!(
10028 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10029 sub_stats.directories,
10030 sub_stats.files,
10031 sub_stats.supported,
10032 sub_stats.skipped,
10033 sub_stats.unsupported
10034 );
10035 write!(
10036 out,
10037 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>"#,
10038 escape_html(sub_name),
10039 escape_html(&sub_rel_path.to_string_lossy()),
10040 escape_html(&sub_size),
10041 escape_html(&stats_json),
10042 escape_html(sub_name),
10043 escape_html(&sub_size),
10044 )
10045 .ok();
10046 }
10047 out.push_str(
10048 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
10049 );
10050 out.push_str(r"</div>");
10051}
10052
10053fn render_language_pills_row(languages: &[&str], out: &mut String) {
10054 use std::fmt::Write as _;
10055 if languages.is_empty() {
10056 out.push_str(
10057 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10058 );
10059 return;
10060 }
10061 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10062 for language in languages {
10063 if let Some(icon) = language_icon_file(language) {
10064 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();
10065 } else if let Some(svg) = language_inline_svg(language) {
10066 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();
10067 } else {
10068 write!(
10069 out,
10070 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10071 escape_html(&language.to_ascii_lowercase()),
10072 escape_html(language)
10073 )
10074 .ok();
10075 }
10076 }
10077}
10078
10079#[allow(clippy::too_many_lines)]
10080fn build_preview_html(
10081 root: &Path,
10082 include_patterns: &[String],
10083 exclude_patterns: &[String],
10084) -> Result<String> {
10085 if !root.exists() {
10086 return Ok(format!(
10087 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10088 escape_html(&display_path(root))
10089 ));
10090 }
10091
10092 let _selected = display_path(root);
10093 let mut stats = PreviewStats::default();
10094 let mut rows = Vec::new();
10095 let mut languages = Vec::new();
10096 let mut budget = PreviewBudget {
10097 shown: 0,
10098 max_entries: 600,
10099 max_depth: 9,
10100 };
10101 let mut next_row_id = 1usize;
10102
10103 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10104 || root.to_string_lossy().into_owned(),
10105 std::string::ToString::to_string,
10106 );
10107 let root_modified = root
10108 .metadata()
10109 .ok()
10110 .and_then(|meta| meta.modified().ok())
10111 .map_or_else(|| "-".to_string(), format_system_time);
10112
10113 rows.push(PreviewRow {
10114 row_id: 0,
10115 parent_row_id: None,
10116 depth: 0,
10117 name: format!("{root_name}/"),
10118 kind: PreviewKind::Dir,
10119 is_dir: true,
10120 language: None,
10121 modified: root_modified,
10122 type_label: "Directory".to_string(),
10123 });
10124 collect_preview_rows(
10125 root,
10126 root,
10127 0,
10128 Some(0),
10129 &mut next_row_id,
10130 &mut budget,
10131 &mut stats,
10132 &mut rows,
10133 &mut languages,
10134 include_patterns,
10135 exclude_patterns,
10136 )?;
10137
10138 let root_size = format_dir_size(dir_size_bytes(root));
10139
10140 let mut out = String::new();
10141 write!(
10142 out,
10143 r#"<div class="explorer-wrap" data-project-size="{}">"#,
10144 escape_html(&root_size)
10145 )
10146 .ok();
10147 out.push_str(r#"<div class="explorer-toolbar compact">"#);
10148 out.push_str(r#"<div class="explorer-title-group">"#);
10149 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10150 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10151 out.push_str(r"</div></div>");
10152
10153 out.push_str(r#"<div class="scope-stats">"#);
10154 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();
10155 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();
10156 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();
10157 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();
10158 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();
10159 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>"#);
10160 out.push_str(r"</div>");
10161
10162 let submodules = sloc_core::detect_submodules(root);
10163 if !submodules.is_empty() {
10164 render_submodule_chips(root, &submodules, &mut out);
10165 }
10166
10167 out.push_str(r#"<div class="scope-info-row">"#);
10168 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10169 render_language_pills_row(&languages, &mut out);
10170 out.push_str(r"</div></div>");
10171 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>"#);
10172 out.push_str(r"</div>");
10173
10174 out.push_str(r#"<div class="file-explorer-shell">"#);
10175 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>"#);
10176 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>"#);
10177 out.push_str(r#"<div class="file-explorer-tree">"#);
10178 for row in rows {
10179 let status_label = row.kind.label();
10180 let lang_attr = row.language.unwrap_or("");
10181 let toggle_html = if row.is_dir {
10182 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10183 .to_string()
10184 } else {
10185 r#"<span class="tree-bullet">•</span>"#.to_string()
10186 };
10187 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();
10188 }
10189 if budget.shown >= budget.max_entries {
10190 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>"#);
10191 }
10192 out.push_str(r"</div></div></div>");
10193
10194 Ok(out)
10195}
10196
10197#[derive(Default)]
10198struct PreviewStats {
10199 directories: usize,
10200 files: usize,
10201 supported: usize,
10202 skipped: usize,
10203 unsupported: usize,
10204}
10205
10206struct PreviewRow {
10207 row_id: usize,
10208 parent_row_id: Option<usize>,
10209 depth: usize,
10210 name: String,
10211 kind: PreviewKind,
10212 is_dir: bool,
10213 language: Option<&'static str>,
10214 modified: String,
10215 type_label: String,
10216}
10217
10218#[derive(Copy, Clone)]
10219enum PreviewKind {
10220 Dir,
10221 Supported,
10222 Skipped,
10223 Unsupported,
10224}
10225
10226impl PreviewKind {
10227 const fn filter_key(self) -> &'static str {
10228 match self {
10229 Self::Dir => "dir",
10230 Self::Supported => "supported",
10231 Self::Skipped => "skipped",
10232 Self::Unsupported => "unsupported",
10233 }
10234 }
10235
10236 const fn label(self) -> &'static str {
10237 match self {
10238 Self::Dir => "dir",
10239 Self::Supported => "supported",
10240 Self::Skipped => "skipped by policy",
10241 Self::Unsupported => "unsupported",
10242 }
10243 }
10244
10245 const fn badge_class(self) -> &'static str {
10246 match self {
10247 Self::Dir => "badge badge-dir",
10248 Self::Supported => "badge badge-scan",
10249 Self::Skipped => "badge badge-skip",
10250 Self::Unsupported => "badge badge-unsupported",
10251 }
10252 }
10253
10254 const fn node_class(self) -> &'static str {
10255 match self {
10256 Self::Dir => "tree-node-dir",
10257 Self::Supported => "tree-node-supported",
10258 Self::Skipped => "tree-node-skipped",
10259 Self::Unsupported => "tree-node-unsupported",
10260 }
10261 }
10262}
10263
10264struct PreviewBudget {
10265 shown: usize,
10266 max_entries: usize,
10267 max_depth: usize,
10268}
10269
10270#[allow(clippy::too_many_arguments)]
10273fn handle_preview_dir_entry(
10274 root: &Path,
10275 path: &Path,
10276 name: &str,
10277 modified: String,
10278 depth: usize,
10279 parent_row_id: Option<usize>,
10280 row_id: usize,
10281 next_row_id: &mut usize,
10282 budget: &mut PreviewBudget,
10283 stats: &mut PreviewStats,
10284 rows: &mut Vec<PreviewRow>,
10285 languages: &mut Vec<&'static str>,
10286 include_patterns: &[String],
10287 exclude_patterns: &[String],
10288) -> Result<()> {
10289 let relative = preview_relative_path(root, path);
10290 if should_skip_preview_directory(&relative, exclude_patterns) {
10291 return Ok(());
10292 }
10293 stats.directories += 1;
10294 rows.push(PreviewRow {
10295 row_id,
10296 parent_row_id,
10297 depth: depth + 1,
10298 name: format!("{name}/"),
10299 kind: PreviewKind::Dir,
10300 is_dir: true,
10301 language: None,
10302 modified,
10303 type_label: "Directory".to_string(),
10304 });
10305 budget.shown += 1;
10306 if !matches!(name, ".git" | "node_modules" | "target") {
10307 collect_preview_rows(
10308 root,
10309 path,
10310 depth + 1,
10311 Some(row_id),
10312 next_row_id,
10313 budget,
10314 stats,
10315 rows,
10316 languages,
10317 include_patterns,
10318 exclude_patterns,
10319 )?;
10320 }
10321 Ok(())
10322}
10323
10324#[allow(clippy::too_many_arguments)]
10326fn handle_preview_file_entry(
10327 root: &Path,
10328 path: &Path,
10329 name: &str,
10330 modified: String,
10331 depth: usize,
10332 parent_row_id: Option<usize>,
10333 row_id: usize,
10334 budget: &mut PreviewBudget,
10335 stats: &mut PreviewStats,
10336 rows: &mut Vec<PreviewRow>,
10337 languages: &mut Vec<&'static str>,
10338 include_patterns: &[String],
10339 exclude_patterns: &[String],
10340) {
10341 let relative = preview_relative_path(root, path);
10342 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10343 return;
10344 }
10345 stats.files += 1;
10346 let kind = classify_preview_file(name);
10347 match kind {
10348 PreviewKind::Supported => stats.supported += 1,
10349 PreviewKind::Skipped => stats.skipped += 1,
10350 PreviewKind::Unsupported => stats.unsupported += 1,
10351 PreviewKind::Dir => {}
10352 }
10353 let language = detect_language_name(name);
10354 if let Some(lang) = language {
10355 if !languages.contains(&lang) {
10356 languages.push(lang);
10357 }
10358 }
10359 rows.push(PreviewRow {
10360 row_id,
10361 parent_row_id,
10362 depth: depth + 1,
10363 name: name.to_owned(),
10364 kind,
10365 is_dir: false,
10366 language,
10367 modified,
10368 type_label: preview_type_label(name, language, kind),
10369 });
10370 budget.shown += 1;
10371}
10372
10373#[allow(clippy::too_many_arguments)]
10374#[allow(clippy::too_many_lines)]
10375fn collect_preview_rows(
10376 root: &Path,
10377 dir: &Path,
10378 depth: usize,
10379 parent_row_id: Option<usize>,
10380 next_row_id: &mut usize,
10381 budget: &mut PreviewBudget,
10382 stats: &mut PreviewStats,
10383 rows: &mut Vec<PreviewRow>,
10384 languages: &mut Vec<&'static str>,
10385 include_patterns: &[String],
10386 exclude_patterns: &[String],
10387) -> Result<()> {
10388 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10389 return Ok(());
10390 }
10391
10392 let mut entries = fs::read_dir(dir)
10393 .with_context(|| format!("failed to read directory {}", dir.display()))?
10394 .filter_map(std::result::Result::ok)
10395 .collect::<Vec<_>>();
10396 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10397
10398 for entry in entries {
10399 if budget.shown >= budget.max_entries {
10400 break;
10401 }
10402
10403 let path = entry.path();
10404 let name = entry.file_name().to_string_lossy().into_owned();
10405 let Ok(metadata) = entry.metadata() else {
10406 continue;
10407 };
10408 let row_id = *next_row_id;
10409 *next_row_id += 1;
10410 let modified = metadata
10411 .modified()
10412 .ok()
10413 .map_or_else(|| "-".to_string(), format_system_time);
10414
10415 if metadata.is_dir() {
10416 handle_preview_dir_entry(
10417 root,
10418 &path,
10419 &name,
10420 modified,
10421 depth,
10422 parent_row_id,
10423 row_id,
10424 next_row_id,
10425 budget,
10426 stats,
10427 rows,
10428 languages,
10429 include_patterns,
10430 exclude_patterns,
10431 )?;
10432 continue;
10433 }
10434
10435 if metadata.is_file() {
10436 handle_preview_file_entry(
10437 root,
10438 &path,
10439 &name,
10440 modified,
10441 depth,
10442 parent_row_id,
10443 row_id,
10444 budget,
10445 stats,
10446 rows,
10447 languages,
10448 include_patterns,
10449 exclude_patterns,
10450 );
10451 }
10452 }
10453
10454 Ok(())
10455}
10456
10457fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10458 if let Some(language) = language {
10459 return format!("{language} source");
10460 }
10461 let lower = name.to_ascii_lowercase();
10462 let ext = Path::new(&lower)
10463 .extension()
10464 .and_then(|e| e.to_str())
10465 .unwrap_or("");
10466 match kind {
10467 PreviewKind::Skipped => {
10468 if lower.ends_with(".min.js") {
10469 "Minified asset".to_string()
10470 } else if [
10471 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10472 ]
10473 .contains(&ext)
10474 {
10475 "Binary or archive".to_string()
10476 } else {
10477 "Skipped file".to_string()
10478 }
10479 }
10480 PreviewKind::Unsupported => {
10481 if ext.is_empty() {
10482 "Unsupported file".to_string()
10483 } else {
10484 format!("{} file", ext.to_ascii_uppercase())
10485 }
10486 }
10487 PreviewKind::Supported => "Supported source".to_string(),
10488 PreviewKind::Dir => "Directory".to_string(),
10489 }
10490}
10491
10492fn format_system_time(time: SystemTime) -> String {
10493 #[allow(clippy::cast_possible_wrap)]
10494 let secs = match time.duration_since(UNIX_EPOCH) {
10495 Ok(duration) => duration.as_secs() as i64,
10496 Err(_) => return "-".to_string(),
10497 };
10498 let days = secs.div_euclid(86_400);
10499 let secs_of_day = secs.rem_euclid(86_400);
10500 let (year, month, day) = civil_from_days(days);
10501 let hour = secs_of_day / 3_600;
10502 let minute = (secs_of_day % 3_600) / 60;
10503 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10504}
10505
10506#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10507fn civil_from_days(days: i64) -> (i32, u32, u32) {
10508 let z = days + 719_468;
10509 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10510 let doe = z - era * 146_097;
10511 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10512 let y = yoe + era * 400;
10513 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10514 let mp = (5 * doy + 2) / 153;
10515 let d = doy - (153 * mp + 2) / 5 + 1;
10516 let m = mp + if mp < 10 { 3 } else { -9 };
10517 let year = y + i64::from(m <= 2);
10518 (year as i32, m as u32, d as u32)
10519}
10520
10521#[allow(clippy::case_sensitive_file_extension_comparisons)]
10524fn detect_language_name(name: &str) -> Option<&'static str> {
10525 let lower = name.to_ascii_lowercase();
10526 if lower.ends_with(".c") || lower.ends_with(".h") {
10527 Some("C")
10528 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10529 .iter()
10530 .any(|s| lower.ends_with(s))
10531 {
10532 Some("C++")
10533 } else if lower.ends_with(".cs") {
10534 Some("C#")
10535 } else if lower.ends_with(".py") {
10536 Some("Python")
10537 } else if lower.ends_with(".sh") {
10538 Some("Shell")
10539 } else if [".ps1", ".psm1", ".psd1"]
10540 .iter()
10541 .any(|s| lower.ends_with(s))
10542 {
10543 Some("PowerShell")
10544 } else {
10545 None
10546 }
10547}
10548
10549fn language_icon_file(language: &str) -> Option<&'static str> {
10550 match language {
10551 "C" => Some("c.png"),
10552 "C++" => Some("cpp.png"),
10553 "C#" => Some("c-sharp.png"),
10554 "Python" => Some("python.png"),
10555 "Shell" => Some("shell.png"),
10556 "PowerShell" => Some("powershell.png"),
10557 "JavaScript" => Some("java-script.png"),
10558 "HTML" => Some("html-5.png"),
10559 "Java" => Some("java.png"),
10560 "Visual Basic" => Some("visual-basic.png"),
10561 "Assembly" => Some("asm.png"),
10562 "Go" => Some("go.png"),
10563 "R" => Some("r.png"),
10564 "XML" => Some("xml.png"),
10565 "Groovy" => Some("groovy.png"),
10566 "Dockerfile" => Some("docker.png"),
10567 "Makefile" => Some("makefile.svg"),
10568 "Perl" => Some("perl.svg"),
10569 _ => None,
10570 }
10571}
10572
10573fn language_inline_svg(language: &str) -> Option<&'static str> {
10578 match language {
10579 "Rust" => Some(
10580 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>"##,
10581 ),
10582 "TypeScript" => Some(
10583 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>"##,
10584 ),
10585 _ => None,
10586 }
10587}
10588
10589#[allow(clippy::case_sensitive_file_extension_comparisons)]
10592fn classify_preview_file(name: &str) -> PreviewKind {
10593 let lower = name.to_ascii_lowercase();
10594
10595 let scannable = [
10596 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10597 ".psm1", ".psd1",
10598 ]
10599 .iter()
10600 .any(|suffix| lower.ends_with(suffix));
10601
10602 if scannable {
10603 PreviewKind::Supported
10604 } else if lower.ends_with(".min.js")
10605 || lower.ends_with(".lock")
10606 || lower.ends_with(".png")
10607 || lower.ends_with(".jpg")
10608 || lower.ends_with(".jpeg")
10609 || lower.ends_with(".gif")
10610 || lower.ends_with(".zip")
10611 || lower.ends_with(".pdf")
10612 || lower.ends_with(".pyc")
10613 || lower.ends_with(".xz")
10614 || lower.ends_with(".tar")
10615 || lower.ends_with(".gz")
10616 {
10617 PreviewKind::Skipped
10618 } else {
10619 PreviewKind::Unsupported
10620 }
10621}
10622
10623fn preview_relative_path(root: &Path, path: &Path) -> String {
10624 path.strip_prefix(root)
10625 .ok()
10626 .unwrap_or(path)
10627 .to_string_lossy()
10628 .replace('\\', "/")
10629 .trim_matches('/')
10630 .to_string()
10631}
10632
10633fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10634 if relative.is_empty() {
10635 return false;
10636 }
10637
10638 exclude_patterns.iter().any(|pattern| {
10639 wildcard_match(pattern, relative)
10640 || wildcard_match(pattern, &format!("{relative}/"))
10641 || wildcard_match(pattern, &format!("{relative}/placeholder"))
10642 })
10643}
10644
10645fn should_include_preview_file(
10646 relative: &str,
10647 include_patterns: &[String],
10648 exclude_patterns: &[String],
10649) -> bool {
10650 if relative.is_empty() {
10651 return true;
10652 }
10653
10654 let included = include_patterns.is_empty()
10655 || include_patterns
10656 .iter()
10657 .any(|pattern| wildcard_match(pattern, relative));
10658 let excluded = exclude_patterns
10659 .iter()
10660 .any(|pattern| wildcard_match(pattern, relative));
10661
10662 included && !excluded
10663}
10664
10665fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10666 let pattern = pattern.trim().replace('\\', "/");
10667 let candidate = candidate.trim().replace('\\', "/");
10668 let p = pattern.as_bytes();
10669 let c = candidate.as_bytes();
10670 let mut pi = 0usize;
10671 let mut ci = 0usize;
10672 let mut star: Option<usize> = None;
10673 let mut star_match = 0usize;
10674
10675 while ci < c.len() {
10676 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10677 pi += 1;
10678 ci += 1;
10679 } else if pi < p.len() && p[pi] == b'*' {
10680 while pi < p.len() && p[pi] == b'*' {
10681 pi += 1;
10682 }
10683 star = Some(pi);
10684 star_match = ci;
10685 } else if let Some(star_pi) = star {
10686 star_match += 1;
10687 ci = star_match;
10688 pi = star_pi;
10689 } else {
10690 return false;
10691 }
10692 }
10693
10694 while pi < p.len() && p[pi] == b'*' {
10695 pi += 1;
10696 }
10697
10698 pi == p.len()
10699}
10700
10701fn escape_html(value: &str) -> String {
10702 value
10703 .replace('&', "&")
10704 .replace('<', "<")
10705 .replace('>', ">")
10706 .replace('"', """)
10707 .replace('\'', "'")
10708}
10709
10710#[derive(Clone)]
10711struct SubmoduleRow {
10712 name: String,
10713 relative_path: String,
10714 files_analyzed: u64,
10715 code_lines: u64,
10716 comment_lines: u64,
10717 blank_lines: u64,
10718 total_physical_lines: u64,
10719 html_url: Option<String>,
10720}
10721
10722#[derive(Template)]
10723#[template(
10724 source = r##"
10725<!doctype html>
10726<html lang="en">
10727<head>
10728 <meta charset="utf-8">
10729 <title>OxideSLOC | tmp-sloc</title>
10730 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10731 <style nonce="{{ csp_nonce }}">
10732 :root {
10733 --bg: #efe9e2;
10734 --surface: #fcfaf7;
10735 --surface-2: #f7f0e8;
10736 --surface-3: #efe3d5;
10737 --line: #dfcfbf;
10738 --line-strong: #cfb29c;
10739 --text: #2f241c;
10740 --muted: #6f6257;
10741 --muted-2: #917f71;
10742 --nav: #b85d33;
10743 --nav-2: #7a371b;
10744 --accent: #2563eb;
10745 --accent-2: #1d4ed8;
10746 --oxide: #b85d33;
10747 --oxide-2: #8f4220;
10748 --success-bg: #eaf9ee;
10749 --success-text: #1c8746;
10750 --warn-bg: #fff2d8;
10751 --warn-text: #926000;
10752 --danger-bg: #fdeaea;
10753 --danger-text: #b33b3b;
10754 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
10755 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
10756 --radius: 14px;
10757 }
10758
10759 body.dark-theme {
10760 --bg: #1b1511;
10761 --surface: #261c17;
10762 --surface-2: #2d221d;
10763 --surface-3: #372922;
10764 --line: #524238;
10765 --line-strong: #6c5649;
10766 --text: #f5ece6;
10767 --muted: #c7b7aa;
10768 --muted-2: #aa9485;
10769 --nav: #b85d33;
10770 --nav-2: #7a371b;
10771 --accent: #6f9bff;
10772 --accent-2: #4a78ee;
10773 --oxide: #d37a4c;
10774 --oxide-2: #b35428;
10775 --success-bg: #163927;
10776 --success-text: #8fe2a8;
10777 --warn-bg: #3c2d11;
10778 --warn-text: #f3cb75;
10779 --danger-bg: #3d1f1f;
10780 --danger-text: #ff9f9f;
10781 --shadow: 0 14px 28px rgba(0,0,0,0.28);
10782 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
10783 }
10784
10785 * { box-sizing: border-box; }
10786 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); }
10787 html { overflow-y: scroll; }
10788 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
10789 .top-nav, .page, .loading { position: relative; z-index: 2; }
10790 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
10791 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
10792 .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); }
10793 .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; }
10794 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
10795 .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)); }
10796 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
10797 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
10798 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
10799 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
10800 .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; }
10801 .nav-project-pill.visible { display:inline-flex; }
10802 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
10803 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
10804 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
10805 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
10806 @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; } }
10807 .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; }
10808 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
10809 .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; }
10810 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
10811 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
10812 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
10813 .theme-toggle .icon-sun { display:none; }
10814 body.dark-theme .theme-toggle .icon-sun { display:block; }
10815 body.dark-theme .theme-toggle .icon-moon { display:none; }
10816 .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;}
10817 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
10818 .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);}
10819 .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;}
10820 .settings-close:hover{color:var(--text);background:var(--surface-2);}
10821 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
10822 .settings-modal-body{padding:14px 16px 16px;}
10823 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
10824 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
10825 .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;}
10826 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
10827 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
10828 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
10829 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
10830 .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;}
10831 .tz-select:focus{border-color:var(--oxide);}
10832 .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; }
10833 .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;}
10834 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
10835 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
10836 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
10837 .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; }
10838 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
10839 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
10840 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
10841 .wb-stats-header { padding: 10px 24px 0; }
10842 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
10843 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
10844 .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; }
10845 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10846 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10847 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10848 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
10849 .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; }
10850 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
10851 .ws-stat-analyzers { position: relative; }
10852 .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; }
10853 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
10854 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
10855 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
10856 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
10857 .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; }
10858 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
10859 .ws-divider { display: none; }
10860 .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%; }
10861 .ws-path-link:hover { color:var(--oxide); }
10862 body.dark-theme .ws-path-link { color:var(--oxide); }
10863 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
10864 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10865 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
10866 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10867 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
10868 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
10869 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
10870 .ws-mini-box-lg { flex:2 1 0; }
10871 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
10872 .ws-mini-box-br { flex:1.5 1 0; }
10873 .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); }
10874 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
10875 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
10876 #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; }
10877 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
10878 .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; }
10879 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
10880 .git-source-banner strong { font-weight:800; color:var(--text); }
10881 .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; }
10882 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
10883 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
10884 .git-source-banner a:hover { text-decoration:underline; }
10885 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
10886 .path-scope-sep { background:var(--line); margin:4px 14px; }
10887 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
10888 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
10889 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
10890 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
10891 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
10892 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
10893 .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; }
10894 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10895 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10896 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10897 .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; }
10898 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
10899 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
10900 [data-wb-tip] { cursor:help; }
10901 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
10902 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
10903 .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; }
10904 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
10905 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
10906 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
10907 .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; }
10908 .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); }
10909 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
10910 .side-info-card { padding: 18px; }
10911 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
10912 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
10913 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
10914 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
10915 .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); }
10916 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
10917 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10918 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
10919 .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; }
10920 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
10921 .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; }
10922 .side-stack::-webkit-scrollbar { display: none; }
10923 .step-nav { padding: 20px 16px; }
10924 .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); }
10925 .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; }
10926 .step-button:hover { background: var(--surface-2); }
10927 .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); }
10928 .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; }
10929 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
10930 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
10931 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
10932 .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); }
10933 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
10934 .step-nav-sum-row:last-child { border-bottom:none; }
10935 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
10936 .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; }
10937 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
10938 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
10939 .quick-scan-section { padding: 10px 4px 14px; }
10940 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
10941 .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; }
10942 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
10943 .quick-scan-btn:active { transform:translateY(0); }
10944 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
10945 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
10946 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
10947 @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);} }
10948 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
10949 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
10950 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
10951 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
10952 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
10953 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
10954 .step-button.done .step-check { opacity:1; }
10955 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
10956 .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; }
10957 .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; }
10958 .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; }
10959 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
10960 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
10961 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
10962 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
10963 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
10964 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
10965 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
10966 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
10967 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
10968 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
10969 .card-body { padding: 22px; }
10970 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
10971 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
10972 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
10973 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
10974 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
10975 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10976 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
10977 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
10978 .field { min-width:0; }
10979 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
10980 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; }
10981 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); }
10982 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
10983 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); }
10984 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
10985 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10986 .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; }
10987 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
10988 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
10989 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
10990 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
10991 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
10992 .input-group.compact { grid-template-columns: 1fr auto auto; }
10993 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
10994 .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)); }
10995 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
10996 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
10997 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
10998 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
10999 .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; }
11000 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11001 .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; }
11002 .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); }
11003 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11004 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11005 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11006 button.secondary { background: var(--surface); }
11007 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11008 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11009 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11010 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11011 .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); }
11012 .section + .wizard-actions { border-top: none; padding-top: 0; }
11013 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11014 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11015 .field-help-grid.coupled-help { margin-top: 12px; }
11016 .field-help-grid.preset-grid { align-items: start; }
11017 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11018 .preset-inline-row .field { margin: 0; }
11019 .preset-inline-row .explainer-card { margin: 0; }
11020 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11021 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11022 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11023 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11024 .preset-kv-row > :last-child { flex:1; min-width:0; }
11025 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11026 .output-field-row .field { margin: 0; }
11027 .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; }
11028 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11029 .step3-subtitle { margin-bottom: 10px; max-width: none; }
11030 .counting-intro { margin-bottom: 8px; max-width: none; }
11031 .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; }
11032 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11033 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11034 .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; }
11035 .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; }
11036 .section-spacer-top { margin-top: 28px; }
11037 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11038 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11039 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11040 .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); }
11041 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11042 .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; }
11043 .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; }
11044 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11045 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11046 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11047 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11048 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11049 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11050 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11051 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11052 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11053 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11054 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11055 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11056 .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); }
11057 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11058 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11059 .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; }
11060 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11061 .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; }
11062 .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; }
11063 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11064 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11065 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11066 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11067 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11068 .advanced-rule-description strong { color: var(--text); }
11069 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11070 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11071 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11072 .review-link:hover { text-decoration: underline; }
11073 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11074 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11075 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11076 .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; }
11077 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11078 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11079 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11080 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11081 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11082 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11083 .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; }
11084 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11085 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11086 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11087 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11088 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11089 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11090 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11091 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11092 .review-card ul { padding-left: 18px; margin: 0; }
11093 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11094 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11095 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11096 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11097 .review-card { min-height: 0; }
11098 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11099 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11100 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11101 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11102 .lang-overflow-chip { position:relative; cursor:default; }
11103 .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; }
11104 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11105 .git-inline-row { align-items:start; }
11106 .mixed-line-card { display:flex; flex-direction:column; }
11107 .preset-inline-row .toggle-card { justify-content: center; }
11108 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11109 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11110 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11111 .explorer-title { font-size: 18px; font-weight: 850; }
11112 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11113 .explorer-subtitle.wide { max-width: none; }
11114 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11115 .better-spacing { align-items:flex-start; justify-content:flex-end; }
11116 .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; }
11117 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11118 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11119 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11120 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11121 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11122 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11123 .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; }
11124 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11125 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11126 .scope-stat-button.supported { background: var(--success-bg); }
11127 .scope-stat-button.skipped { background: var(--warn-bg); }
11128 .scope-stat-button.unsupported { background: var(--danger-bg); }
11129 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11130 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11131 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11132 [data-tooltip] { position: relative; }
11133 [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); }
11134 [data-tooltip]:hover::after { display: block; }
11135 .scope-stat-button[data-tooltip] { cursor: pointer; }
11136 .badge[data-tooltip] { cursor: help; }
11137 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11138 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11139 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11140 .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; }
11141 .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; }
11142 code { display:inline-block; margin-top:0; padding:2px 7px; }
11143 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11144 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11145 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11146 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11147 .language-pill.muted-pill { color: var(--muted); }
11148 button.language-pill { appearance:none; cursor:pointer; }
11149 .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); }
11150 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11151 .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; }
11152 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11153 .file-explorer-search-row { margin-left: auto; }
11154 .explorer-filter-select { min-width: 170px; width: 170px; }
11155 .explorer-search { min-width: 300px; width: 300px; }
11156 .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); }
11157 .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; }
11158 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11159 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11160 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11161 .file-explorer-tree { max-height: 640px; overflow:auto; }
11162 .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); }
11163 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11164 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11165 .tree-row.hidden-by-filter { display:none !important; }
11166 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11167 .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; }
11168 .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; }
11169 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11170 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11171 .tree-node { display:inline-flex; align-items:center; min-width:0; }
11172 .tree-node-dir { color: var(--text); font-weight: 800; }
11173 .tree-node-supported { color: var(--success-text); }
11174 .tree-node-skipped { color: var(--warn-text); }
11175 .tree-node-unsupported { color: var(--danger-text); }
11176 .tree-node-more { color: var(--muted-2); font-style: italic; }
11177 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11178 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11179 .tree-status-cell { display:flex; justify-content:flex-start; }
11180 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11181 .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; }
11182 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11183 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11184 .cov-scan-idle { display:none; }
11185 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11186 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11187 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11188 .cov-scan-title { font-weight:600; font-size:12.5px; }
11189 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11190 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11191 .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; }
11192 .cov-scan-use:hover { opacity:.75; }
11193 .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; }
11194 .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; }
11195 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11196 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11197 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11198 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11199 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11200 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11201 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11202 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11203 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11204 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11205 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11206 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11207 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11208 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11209 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11210 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11211 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11212 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11213 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11214 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11215 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11216 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11217 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11218 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11219 .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); }
11220 .loading.active { display:flex; }
11221 .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; }
11222 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11223 .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; }
11224 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11225 .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; }
11226 .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; }
11227 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11228 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11229 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11230 .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; }
11231 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11232 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11233 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11234 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11235 .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; }
11236 .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; }
11237 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11238 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11239 .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; }
11240 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11241 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11242 .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; }
11243 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11244 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11245 .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; }
11246 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11247 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11248 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11249 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11250 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11251 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11252 .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; }
11253 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11254 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11255 .hidden { display:none !important; }
11256 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11257 .site-footer a{color:var(--muted);}
11258 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11259 @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; } }
11260 .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;}
11261 @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));}}
11262 .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;}
11263 .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; }
11264 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11265 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11266 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11267 .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; }
11268 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11269 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11270 .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; }
11271 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11272 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11273 .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; }
11274 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11275 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11276 .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; }
11277 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11278 .info-icon-btn:hover { color:var(--text); }
11279 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); }
11280 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11281 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11282 .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;}
11283 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11284 .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;}
11285 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11286 </style>
11287</head>
11288<body>
11289 <div class="background-watermarks" aria-hidden="true">
11290 <img src="/images/logo/logo-text.png" alt="" />
11291 <img src="/images/logo/logo-text.png" alt="" />
11292 <img src="/images/logo/logo-text.png" alt="" />
11293 <img src="/images/logo/logo-text.png" alt="" />
11294 <img src="/images/logo/logo-text.png" alt="" />
11295 <img src="/images/logo/logo-text.png" alt="" />
11296 <img src="/images/logo/logo-text.png" alt="" />
11297 <img src="/images/logo/logo-text.png" alt="" />
11298 <img src="/images/logo/logo-text.png" alt="" />
11299 <img src="/images/logo/logo-text.png" alt="" />
11300 <img src="/images/logo/logo-text.png" alt="" />
11301 <img src="/images/logo/logo-text.png" alt="" />
11302 <img src="/images/logo/logo-text.png" alt="" />
11303 <img src="/images/logo/logo-text.png" alt="" />
11304 </div>
11305 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11306 <div class="top-nav">
11307 <div class="top-nav-inner">
11308 <a class="brand" href="/">
11309 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11310 <div class="brand-copy">
11311 <div class="brand-title">OxideSLOC</div>
11312 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11313 </div>
11314 </a>
11315 <div class="nav-project-slot">
11316 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11317 <span class="nav-project-label">Project</span>
11318 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11319 </div>
11320 </div>
11321 <div class="nav-status">
11322 <a class="nav-pill" href="/">Home</a>
11323 <div class="nav-dropdown">
11324 <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>
11325 <div class="nav-dropdown-menu">
11326 <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>
11327 </div>
11328 </div>
11329 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11330 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11331 <div class="nav-dropdown">
11332 <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>
11333 <div class="nav-dropdown-menu">
11334 <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>
11335 </div>
11336 </div>
11337 <div class="server-status-wrap" id="server-status-wrap">
11338 <div class="nav-pill server-online-pill" id="server-status-pill">
11339 <span class="status-dot" id="status-dot"></span>
11340 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11341 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11342 </div>
11343 <div class="server-status-tip">
11344 {% if server_mode %}
11345 OxideSLOC is running in server mode — accessible on your LAN.
11346 {% else %}
11347 OxideSLOC is running locally — only accessible from this machine.
11348 {% endif %}
11349 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11350 </div>
11351 </div>
11352 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11353 <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>
11354 </button>
11355 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11356 <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>
11357 <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>
11358 </button>
11359 </div>
11360 </div>
11361 </div>
11362
11363 <div class="loading" id="loading">
11364 <div class="loading-card">
11365 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11366 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11367 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
11368 <div class="lc-path" id="lc-path"></div>
11369 <div class="lc-metrics" id="lc-metrics">
11370 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11371 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11372 </div>
11373 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11374 <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>
11375 <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>
11376 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11377 <div class="lc-actions hidden" id="lc-actions">
11378 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11379 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11380 </div>
11381 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11382 <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>
11383 Cancel scan
11384 </button>
11385 </div>
11386 </div>
11387
11388 <div class="page">
11389 <div class="workbench-strip">
11390 <div class="workbench-box wb-stats">
11391 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11392 <span class="wb-stats-title">Analysis session</span>
11393 </div>
11394 <div class="ws-left">
11395 <div class="ws-stat ws-stat-analyzers">
11396 <span class="ws-label">Analyzers</span>
11397 <span class="ws-value">
11398 <span class="ws-badge">41 languages</span>
11399 </span>
11400 <div class="ws-lang-tooltip">
11401 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11402 <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>
11403 <div class="ws-lang-grid">
11404 <span class="ws-lang-item">Assembly</span>
11405 <span class="ws-lang-item">C</span>
11406 <span class="ws-lang-item">C++</span>
11407 <span class="ws-lang-item">C#</span>
11408 <span class="ws-lang-item">Clojure</span>
11409 <span class="ws-lang-item">CSS</span>
11410 <span class="ws-lang-item">Dart</span>
11411 <span class="ws-lang-item">Dockerfile</span>
11412 <span class="ws-lang-item">Elixir</span>
11413 <span class="ws-lang-item">Erlang</span>
11414 <span class="ws-lang-item">F#</span>
11415 <span class="ws-lang-item">Go</span>
11416 <span class="ws-lang-item">Groovy</span>
11417 <span class="ws-lang-item">Haskell</span>
11418 <span class="ws-lang-item">HTML</span>
11419 <span class="ws-lang-item">Java</span>
11420 <span class="ws-lang-item">JavaScript</span>
11421 <span class="ws-lang-item">Julia</span>
11422 <span class="ws-lang-item">Kotlin</span>
11423 <span class="ws-lang-item">Lua</span>
11424 <span class="ws-lang-item">Makefile</span>
11425 <span class="ws-lang-item">Nim</span>
11426 <span class="ws-lang-item">Obj-C</span>
11427 <span class="ws-lang-item">OCaml</span>
11428 <span class="ws-lang-item">Perl</span>
11429 <span class="ws-lang-item">PHP</span>
11430 <span class="ws-lang-item">PowerShell</span>
11431 <span class="ws-lang-item">Python</span>
11432 <span class="ws-lang-item">R</span>
11433 <span class="ws-lang-item">Ruby</span>
11434 <span class="ws-lang-item">Rust</span>
11435 <span class="ws-lang-item">Scala</span>
11436 <span class="ws-lang-item">SCSS</span>
11437 <span class="ws-lang-item">Shell</span>
11438 <span class="ws-lang-item">SQL</span>
11439 <span class="ws-lang-item">Svelte</span>
11440 <span class="ws-lang-item">Swift</span>
11441 <span class="ws-lang-item">TypeScript</span>
11442 <span class="ws-lang-item">Vue</span>
11443 <span class="ws-lang-item">XML</span>
11444 <span class="ws-lang-item">Zig</span>
11445 </div>
11446 </div>
11447 </div>
11448 <div class="ws-divider"></div>
11449 <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>
11450 <div class="ws-divider"></div>
11451 <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.">
11452 <span class="ws-label">Output</span>
11453 <span class="ws-value">
11454 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11455 <span id="ws-output-root">project/sloc</span>
11456 </button>
11457 </span>
11458 </div>
11459 </div>
11460 </div>
11461 <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.">
11462 <div class="ws-history-label">Scan history</div>
11463 <div class="ws-history-inner">
11464 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11465 <div class="ws-mini-label">Scans</div>
11466 <div class="ws-mini-value" id="ws-scan-count">—</div>
11467 </div>
11468 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11469 <div class="ws-mini-label">Last Scan</div>
11470 <div class="ws-mini-value" id="ws-last-scan">—</div>
11471 </div>
11472 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11473 <div class="ws-mini-label">Branch</div>
11474 <div class="ws-mini-value" id="ws-branch">—</div>
11475 </div>
11476 </div>
11477 </div>
11478 </div>
11479
11480 <div class="layout">
11481 <aside class="side-stack">
11482 <section class="step-nav">
11483 <h3>Guided scan setup</h3>
11484 <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>
11485 <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>
11486 <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>
11487 <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>
11488
11489 <div class="step-steps-divider"></div>
11490
11491 <div class="step-nav-info" id="step-nav-info">
11492 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11493 <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>
11494 </div>
11495
11496 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11497 <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>
11498 <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>
11499 <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>
11500 </div>
11501
11502 <div class="quick-scan-divider"></div>
11503 <div class="quick-scan-section">
11504 <div class="quick-scan-label">No customization needed?</div>
11505 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11506 <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>
11507 Quick Scan
11508 </button>
11509 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11510 </div>
11511
11512 <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>
11513 </section>
11514
11515 </aside>
11516
11517 <section class="card">
11518 <div class="card-header">
11519 <div class="card-title-row">
11520 <div>
11521 <h1 class="card-title">Guided scan configuration</h1>
11522 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11523 </div>
11524 <div class="wizard-progress" aria-label="Scan setup progress">
11525 <div class="wizard-progress-top">
11526 <span class="wizard-progress-label">Setup progress</span>
11527 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11528 </div>
11529 <div class="wizard-progress-track">
11530 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11531 </div>
11532 </div>
11533 </div>
11534 </div>
11535 <div class="card-body">
11536 <form method="post" action="/analyze" id="analyze-form">
11537 <div class="wizard-step active" data-step="1">
11538 <div class="section">
11539 <div class="section-kicker">Step 1</div>
11540 <h2>Select project and preview scope</h2>
11541 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11542 <div class="field">
11543 <label for="path">Project path</label>
11544 {% if !git_repo.is_empty() %}
11545 <div class="git-source-banner">
11546 <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>
11547 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11548 <a href="/git-browser">← Back to Git Browser</a>
11549 </div>
11550 {% endif %}
11551 <div class="path-scope-grid">
11552 {% if !git_repo.is_empty() %}
11553 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11554 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11555 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11556 {% else %}
11557 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11558 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11559 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11560 {% endif %}
11561 <div class="path-scope-sep"></div>
11562 <div class="scope-legend-row">
11563 <span class="scope-legend-label">Scope legend:</span>
11564 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11565 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11566 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11567 </div>
11568 </div>
11569 {% if git_repo.is_empty() %}
11570 {% if server_mode %}
11571 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11572 ℹ️ Files are compressed and streamed — no fixed size limit.
11573 </div>
11574 {% endif %}
11575 <div class="path-info-row">
11576 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11577 <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>
11578 <span id="project-size-text">Project size: —</span>
11579 </button>
11580 </div>
11581 {% else %}
11582 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11583 {% endif %}
11584 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11585 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11586 </div>
11587
11588 <div class="scope-preview-divider" aria-hidden="true"></div>
11589
11590 <div id="preview-panel">
11591 <div class="preview-error">Loading preview...</div>
11592 </div>
11593 </div>
11594
11595 <div class="section" style="margin-top:14px;">
11596 <div class="preset-inline-row git-inline-row">
11597 <div class="toggle-card" style="margin:0;">
11598 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11599 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11600 <label class="checkbox">
11601 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11602 <div>
11603 <span>Detect and separate git submodules</span>
11604 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11605 </div>
11606 </label>
11607 </div>
11608 <div class="explainer-card prominent" style="margin:0;">
11609 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11610 <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>
11611 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11612 path = libs/core
11613 url = https://github.com/org/core.git
11614
11615[submodule "libs/ui"]
11616 path = libs/ui
11617 url = https://github.com/org/ui.git</div>
11618 </div>
11619 </div>
11620 </div>
11621
11622 <div class="section">
11623 <div class="field-grid">
11624 <div class="field">
11625 <label for="include_globs">Include globs</label>
11626 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
11627 <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>
11628 </div>
11629 <div class="field">
11630 <label for="exclude_globs">Exclude globs</label>
11631 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
11632 <div id="quick-exclude-chips" class="quick-excl-row">
11633 <span class="quick-excl-label">Quick add:</span>
11634 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11635 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11636 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11637 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11638 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11639 <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>
11640 </div>
11641 <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>
11642 </div>
11643 </div>
11644 <div class="glob-guidance-grid">
11645 <div class="glob-guidance-card">
11646 <strong>How to read them</strong>
11647 <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>
11648 </div>
11649 <div class="glob-guidance-card">
11650 <strong>Common include examples</strong>
11651 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11652 </div>
11653 <div class="glob-guidance-card">
11654 <strong>Common exclude examples</strong>
11655 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11656 </div>
11657 </div>
11658 </div>
11659
11660 <div class="section" style="margin-top:14px;">
11661 <div class="preset-inline-row git-inline-row">
11662 <div class="toggle-card" style="margin:0;">
11663 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11664 <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>
11665 <div class="field" style="margin:0;">
11666 <div class="input-group compact">
11667 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11668 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11669 </div>
11670 <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>
11671 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11672 </div>
11673 </div>
11674 <div class="explainer-card prominent" style="margin:0;">
11675 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11676 <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>
11677 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11678lcov --capture --directory . --output-file coverage/lcov.info
11679
11680# C / C++ — llvm-cov (LCOV)
11681llvm-profdata merge -sparse default.profraw -o default.profdata
11682llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11683
11684# C# — coverlet (Cobertura XML)
11685dotnet test --collect:"XPlat Code Coverage"
11686
11687# Python — pytest-cov (Cobertura XML)
11688pytest --cov --cov-report=xml
11689
11690# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11691./gradlew jacocoTestReport</div>
11692 </div>
11693 </div>
11694 </div>
11695
11696 <div class="wizard-actions">
11697 <div class="left"></div>
11698 <div class="right">
11699 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11700 </div>
11701 </div>
11702 </div>
11703
11704 <div class="wizard-step" data-step="2">
11705 <div class="section">
11706 <div class="section-kicker">Step 2</div>
11707 <h2>Choose counting behavior</h2>
11708 <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>
11709 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
11710 <div class="subsection-bar">Primary line classification</div>
11711 <div class="preset-kv-row">
11712 <div class="toggle-card mixed-line-card" style="margin:0;">
11713 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11714 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11715 <select id="mixed_line_policy" name="mixed_line_policy">
11716 <option value="code_only">Code only</option>
11717 <option value="code_and_comment">Code and comment</option>
11718 <option value="comment_only">Comment only</option>
11719 <option value="separate_mixed_category">Separate mixed category</option>
11720 </select>
11721 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11722 </div>
11723 <div class="explainer-card prominent" style="margin:0;">
11724 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11725 <div class="explainer-body" id="mixed-policy-description"></div>
11726 <div class="code-sample" id="mixed-policy-example"></div>
11727 </div>
11728 </div>
11729 </div>
11730
11731 <div class="subsection-bar">Additional scan rules</div>
11732 <div class="scan-rules-grid">
11733 <div class="preset-inline-row">
11734 <div class="toggle-card" style="margin:0;">
11735 <div class="field-help-title">Generated files</div>
11736 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11737 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11738 </div>
11739 <div class="explainer-card prominent" style="margin:0;">
11740 <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>
11741 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
11742# Files matching codegen patterns are excluded:
11743# *.generated.cs *.pb.go *.g.dart</div>
11744 </div>
11745 </div>
11746 <div class="preset-inline-row">
11747 <div class="toggle-card" style="margin:0;">
11748 <div class="field-help-title">Minified files</div>
11749 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
11750 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11751 </div>
11752 <div class="explainer-card prominent" style="margin:0;">
11753 <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>
11754 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
11755# Heuristic: very long lines + low whitespace ratio
11756# jquery.min.js bundle.min.css → skipped</div>
11757 </div>
11758 </div>
11759 <div class="preset-inline-row">
11760 <div class="toggle-card" style="margin:0;">
11761 <div class="field-help-title">Vendor directories</div>
11762 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
11763 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11764 </div>
11765 <div class="explainer-card prominent" style="margin:0;">
11766 <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>
11767 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
11768# Directories named vendor/ node_modules/ third_party/
11769# → entire subtree is excluded from totals</div>
11770 </div>
11771 </div>
11772 <div class="preset-inline-row">
11773 <div class="toggle-card" style="margin:0;">
11774 <div class="field-help-title">Lockfiles and manifests</div>
11775 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
11776 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
11777 </div>
11778 <div class="explainer-card prominent" style="margin:0;">
11779 <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>
11780 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
11781# Files like package-lock.json Cargo.lock yarn.lock
11782# → skipped unless this is enabled</div>
11783 </div>
11784 </div>
11785 <div class="preset-inline-row">
11786 <div class="toggle-card" style="margin:0;">
11787 <div class="field-help-title">Binary handling</div>
11788 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
11789 <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>
11790 </div>
11791 <div class="explainer-card prominent" style="margin:0;">
11792 <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>
11793 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
11794# Detected via long lines + low whitespace heuristic
11795# .png .exe .so → skipped silently</div>
11796 </div>
11797 </div>
11798 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
11799 <div class="toggle-card" style="margin:0;">
11800 <div class="field-help-title">Python docstrings</div>
11801 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
11802 <label class="checkbox">
11803 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
11804 <span>Count as comment-style lines</span>
11805 </label>
11806 </div>
11807 <div class="explainer-card prominent" style="margin:0;">
11808 <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>
11809 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
11810 </div>
11811 </div>
11812 </div>
11813 <div class="subsection-bar">IEEE 1045-1992 counting</div>
11814 <div class="scan-rules-grid">
11815 <div class="preset-inline-row">
11816 <div class="toggle-card" style="margin:0;">
11817 <div class="field-help-title">Continuation lines</div>
11818 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
11819 <select name="continuation_line_policy" id="continuation_line_policy">
11820 <option value="each_physical_line" selected>Each physical line (default)</option>
11821 <option value="collapse_to_logical">Collapse to logical line</option>
11822 </select>
11823 </div>
11824 <div class="explainer-card prominent" style="margin:0;">
11825 <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>
11826 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
11827 ((a) > (b) ? (a) : (b))
11828# each_physical_line → 2 SLOC
11829# collapse_to_logical → 1 SLOC</div>
11830 </div>
11831 </div>
11832 <div class="preset-inline-row">
11833 <div class="toggle-card" style="margin:0;">
11834 <div class="field-help-title">Block-comment blanks</div>
11835 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
11836 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
11837 <option value="count_as_comment" selected>Count as comment (default)</option>
11838 <option value="count_as_blank">Count as blank</option>
11839 </select>
11840 </div>
11841 <div class="explainer-card prominent" style="margin:0;">
11842 <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>
11843 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
11844 * Summary line
11845 * ← blank inside block comment
11846 * Detail line
11847 */
11848# count_as_comment → blank counts toward comments
11849# count_as_blank → blank counts toward blanks</div>
11850 </div>
11851 </div>
11852 <div class="preset-inline-row">
11853 <div class="toggle-card" style="margin:0;">
11854 <div class="field-help-title">Compiler directives</div>
11855 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
11856 <select name="count_compiler_directives" id="count_compiler_directives">
11857 <option value="enabled" selected>Include in code SLOC (default)</option>
11858 <option value="disabled">Exclude from code SLOC</option>
11859 </select>
11860 </div>
11861 <div class="explainer-card prominent" style="margin:0;">
11862 <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>
11863 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
11864#define BUF 256 ← compiler directive
11865int main() { … } ← code
11866# enabled → 3 code SLOC
11867# disabled → 1 code SLOC + 2 directive lines</div>
11868 </div>
11869 </div>
11870 </div>
11871
11872 <div class="always-tracked-tip">
11873 <div class="always-tracked-tip-icon">ℹ</div>
11874 <div class="always-tracked-tip-body">
11875 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
11876 <h4>Comment and blank-line basics & Lines on the boundary</h4>
11877 <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>
11878 </div>
11879 </div>
11880
11881 <div class="wizard-actions">
11882 <div class="left">
11883 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
11884 </div>
11885 <div class="right">
11886 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
11887 </div>
11888 </div>
11889 </div>
11890
11891 <div class="wizard-step" data-step="3">
11892 <div class="section">
11893 <div class="section-kicker">Step 3</div>
11894 <h2>Output and report identity</h2>
11895 <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>
11896 <div class="preset-kv-row">
11897 <div class="toggle-card" style="margin:0;">
11898 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
11899 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
11900 <select id="scan_preset">
11901 <option value="balanced">Balanced local scan</option>
11902 <option value="code_focused">Code focused</option>
11903 <option value="comment_audit">Comment audit</option>
11904 <option value="deep_review">Deep review</option>
11905 </select>
11906 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
11907 </div>
11908 <div class="explainer-card">
11909 <div class="field-help-title">Selected scan preset</div>
11910 <div class="explainer-body" id="scan-preset-description"></div>
11911 <div class="preset-summary-row" id="scan-preset-summary"></div>
11912 <div class="code-sample" id="scan-preset-example"></div>
11913 <div class="preset-note" id="scan-preset-note"></div>
11914 </div>
11915 </div>
11916 <hr class="step3-separator" />
11917 <div class="preset-kv-row">
11918 <div class="toggle-card" style="margin:0;">
11919 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
11920 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
11921 <select id="artifact_preset">
11922 <option value="review">Review bundle</option>
11923 <option value="full">Full bundle</option>
11924 <option value="html_only">HTML only</option>
11925 <option value="machine">Machine bundle</option>
11926 </select>
11927 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
11928 </div>
11929 <div class="explainer-card">
11930 <div class="field-help-title">Selected artifact preset</div>
11931 <div class="explainer-body" id="artifact-preset-description"></div>
11932 <div class="preset-summary-row" id="artifact-preset-summary"></div>
11933 <div class="code-sample" id="artifact-preset-example"></div>
11934 </div>
11935 </div>
11936 </div>
11937
11938 <div class="section section-spacer-top">
11939 <div class="output-field-row">
11940 <div class="field">
11941 <label for="output_dir">Output directory</label>
11942 {% if server_mode %}
11943 <div class="input-group compact">
11944 <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);" />
11945 </div>
11946 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
11947 {% else %}
11948 <div class="input-group compact">
11949 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
11950 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
11951 <button type="button" class="mini-button" id="use-default-output">Use default</button>
11952 </div>
11953 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
11954 {% endif %}
11955 </div>
11956 <div class="output-field-aside">
11957 <strong>Where reports land</strong>
11958 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.
11959 </div>
11960 </div>
11961 </div>
11962
11963 <div class="section section-spacer-top">
11964 <div class="output-field-row">
11965 <div class="field">
11966 <label for="report_title">Report title</label>
11967 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
11968 <div class="hint">Appears in HTML and PDF output headers.</div>
11969 </div>
11970 <div class="output-field-aside">
11971 <strong>Shown in exported artifacts</strong>
11972 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.
11973 </div>
11974 </div>
11975 </div>
11976
11977 <div class="section section-spacer-top">
11978 <div class="output-field-row">
11979 <div class="field">
11980 <label for="report_header_footer">Report header / footer</label>
11981 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
11982 <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>
11983 </div>
11984 <div class="output-field-aside">
11985 <strong>Page-level identification</strong>
11986 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.
11987 </div>
11988 </div>
11989 </div>
11990
11991 <div class="section">
11992 <div class="section-kicker">Artifacts</div>
11993 <div class="artifact-grid" style="margin-bottom:24px;">
11994 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
11995 <div class="marker">✓</div>
11996 <div class="artifact-icon">H</div>
11997 <h4>HTML report</h4>
11998 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
11999 <div class="artifact-tags">
12000 <span class="soft-chip">Best for visual review</span>
12001 <span class="soft-chip">Embeddable preview</span>
12002 </div>
12003 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12004 </div>
12005 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12006 <div class="marker">✓</div>
12007 <div class="artifact-icon">P</div>
12008 <h4>PDF export</h4>
12009 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12010 <div class="artifact-tags">
12011 <span class="soft-chip">Portable snapshot</span>
12012 <span class="soft-chip">Good for handoff</span>
12013 </div>
12014 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12015 </div>
12016 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12017 <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>
12018 <div class="marker">✓</div>
12019 <div class="artifact-icon" style="color:var(--muted);">J</div>
12020 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12021 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12022 <div class="artifact-tags">
12023 <span class="soft-chip">Required for compare</span>
12024 <span class="soft-chip">Auto-enabled</span>
12025 </div>
12026 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12027 </div>
12028 </div>
12029 <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>
12030 </div>
12031
12032 <div class="wizard-actions">
12033 <div class="left">
12034 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12035 </div>
12036 <div class="right">
12037 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12038 </div>
12039 </div>
12040 </div>
12041
12042 <div class="wizard-step" data-step="4">
12043 <div class="section">
12044 <div class="section-kicker">Step 4</div>
12045 <h2>Review selections and run</h2>
12046 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12047 <div class="review-grid">
12048 <div class="review-card highlight">
12049 <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>
12050 <ul id="review-scan-summary"></ul>
12051 </div>
12052 <div class="review-card highlight">
12053 <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>
12054 <ul id="review-count-summary"></ul>
12055 </div>
12056 <div class="review-card">
12057 <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>
12058 <ul id="review-artifact-summary"></ul>
12059 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12060 </div>
12061 <div class="review-card">
12062 <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>
12063 <ul id="review-preview-summary"></ul>
12064 </div>
12065 </div>
12066 </div>
12067
12068 <div class="wizard-actions">
12069 <div class="left">
12070 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12071 </div>
12072 <div class="right">
12073 <button type="submit" id="submit-button" class="primary">Run analysis</button>
12074 </div>
12075 </div>
12076 </div>
12077 {% if server_mode %}
12078 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12079 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12080 {% endif %}
12081 </form>
12082 </div>
12083 </section>
12084 </div>
12085 </div>
12086
12087 <script nonce="{{ csp_nonce }}">
12088 (function () {
12089 function startScanPhase() {
12090 var phaseEl = document.getElementById("scan-phase");
12091 if (!phaseEl) return;
12092 var phases = [
12093 "Discovering files...",
12094 "Decoding file encodings...",
12095 "Detecting languages...",
12096 "Analyzing source lines...",
12097 "Applying counting policies...",
12098 "Aggregating results...",
12099 "Rendering report..."
12100 ];
12101 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12102 var i = 0;
12103 function next() {
12104 phaseEl.style.opacity = "0";
12105 setTimeout(function () {
12106 phaseEl.textContent = phases[i];
12107 phaseEl.style.opacity = "0.85";
12108 var delay = durations[i] || 1800;
12109 i++;
12110 if (i < phases.length) { setTimeout(next, delay); }
12111 }, 200);
12112 }
12113 next();
12114 }
12115
12116 var form = document.getElementById("analyze-form");
12117 var loading = document.getElementById("loading");
12118 var submitButton = document.getElementById("submit-button");
12119 var pathInput = document.getElementById("path");
12120 var GIT_MODE = !!(pathInput && pathInput.readOnly);
12121 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12122 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12123 var outputDirInput = document.getElementById("output_dir");
12124 var reportTitleInput = document.getElementById("report_title");
12125 var previewPanel = document.getElementById("preview-panel");
12126 var refreshButton = document.getElementById("refresh-preview");
12127 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12128 var useSamplePath = document.getElementById("use-sample-path");
12129 var useDefaultOutput = document.getElementById("use-default-output");
12130 var browsePath = document.getElementById("browse-path");
12131 var browseOutputDir = document.getElementById("browse-output-dir");
12132 var browseCoverage = document.getElementById("browse-coverage");
12133 var coverageInput = document.getElementById("coverage_file");
12134 var covScanStatus = document.getElementById("cov-scan-status");
12135 var coverageSuggestTimer = null;
12136 var covAutoFilled = false;
12137 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12138 function fmtBytes(b) {
12139 b = Number(b) || 0;
12140 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12141 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12142 if (b >= 1024) return Math.round(b / 1024) + ' KB';
12143 return b + ' B';
12144 }
12145 var themeToggle = document.getElementById("theme-toggle");
12146
12147 function showBannerToast(msg, isError, opts) {
12148 opts = opts || {};
12149 var t = document.createElement('div');
12150 t.className = isError ? 'toast-error' : 'toast-success';
12151 var topPos = opts.top ? '80px' : null;
12152 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12153 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12154 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12155 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12156 if (opts.icon) {
12157 var inner = document.createElement('span');
12158 inner.innerHTML = opts.icon + ' ';
12159 t.appendChild(inner);
12160 }
12161 t.appendChild(document.createTextNode(msg));
12162 document.body.appendChild(t);
12163 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12164 }
12165 var mixedLinePolicy = document.getElementById("mixed_line_policy");
12166 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12167 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12168 var scanPreset = document.getElementById("scan_preset");
12169 var artifactPreset = document.getElementById("artifact_preset");
12170 var includeGlobsInput = document.getElementById("include_globs");
12171 var excludeGlobsInput = document.getElementById("exclude_globs");
12172
12173 // Quick-exclude chips — append pattern to exclude_globs textarea.
12174 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12175 chip.addEventListener("click", function() {
12176 var pattern = chip.getAttribute("data-pattern") || "";
12177 if (!pattern || !excludeGlobsInput) return;
12178 var current = excludeGlobsInput.value.trim();
12179 // For the "skip all" chip, replace any existing dep patterns cleanly.
12180 var patterns = pattern.split("\n");
12181 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12182 var added = false;
12183 patterns.forEach(function(p) {
12184 p = p.trim();
12185 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12186 });
12187 if (added) {
12188 excludeGlobsInput.value = lines.join("\n");
12189 excludeGlobsInput.dispatchEvent(new Event("input"));
12190 }
12191 chip.classList.add("active");
12192 });
12193 });
12194
12195 var liveReportTitle = document.getElementById("live-report-title");
12196 var navProjectPill = document.getElementById("nav-project-pill");
12197 var navProjectTitle = document.getElementById("nav-project-title");
12198 var reportTitlePreview = null;
12199 var wizardProgressFill = document.getElementById("wizard-progress-fill");
12200 var wizardProgressValue = document.getElementById("wizard-progress-value");
12201 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12202 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12203 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12204 var reportTitleTouched = false;
12205 var currentStep = 1;
12206 var previewTimer = null;
12207 var quickScanBtn = document.getElementById("quick-scan-btn");
12208
12209 function dismissAnalysisModal() {
12210 if (loading) loading.classList.remove("active");
12211 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12212 var el = document.getElementById(id);
12213 if (el) el.classList.add("hidden");
12214 });
12215 var cancelBtn = document.getElementById("lc-cancel-btn");
12216 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12217 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12218 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12219 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12220 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12221 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12222 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12223 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12224 }
12225
12226 var lcDismissBtn = document.getElementById("lc-dismiss");
12227 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12228
12229 function startAsyncAnalysis(formData) {
12230 var gitRepo = (formData.get("git_repo") || "").toString();
12231 var gitRef = (formData.get("git_ref") || "").toString();
12232 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12233 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12234
12235 var pathEl = document.getElementById("lc-path");
12236 if (pathEl) pathEl.textContent = displayPath;
12237
12238 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12239 var el = document.getElementById(id);
12240 if (el) el.classList.add("hidden");
12241 });
12242 var cancelBtn = document.getElementById("lc-cancel-btn");
12243 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12244 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12245 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12246 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12247 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12248 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
12249
12250 if (loading) loading.classList.add("active");
12251
12252 var startTime = Date.now();
12253 var elapsedTimer = setInterval(function() {
12254 var s = Math.floor((Date.now() - startTime) / 1000);
12255 var el = document.getElementById("lc-elapsed");
12256 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12257 }, 1000);
12258
12259 var warnShown = false, pollRetries = 0, activeWaitId = null;
12260
12261 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12262
12263 function lcShowCancelled() {
12264 clearInterval(elapsedTimer);
12265 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12266 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12267 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12268 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12269 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12270 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12271 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12272 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12273 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12274 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12275 }
12276
12277 var lcCancelBtn = document.getElementById("lc-cancel-btn");
12278 if (lcCancelBtn) {
12279 lcCancelBtn.onclick = function() {
12280 if (!activeWaitId) { dismissAnalysisModal(); return; }
12281 lcCancelBtn.disabled = true;
12282 lcCancelBtn.textContent = "Cancelling…";
12283 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12284 .then(function() { lcShowCancelled(); })
12285 .catch(function() { lcShowCancelled(); });
12286 };
12287 }
12288
12289 function lcShowError(msg) {
12290 clearInterval(elapsedTimer);
12291 lcSetPhase("Failed");
12292 var msgEl = document.getElementById("lc-err-msg");
12293 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12294 var errEl = document.getElementById("lc-err");
12295 var actEl = document.getElementById("lc-actions");
12296 if (errEl) errEl.classList.remove("hidden");
12297 if (actEl) actEl.classList.remove("hidden");
12298 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12299 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12300 }
12301
12302 function lcPoll(waitId) {
12303 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12304 .then(function(r) {
12305 if (!r.ok) throw new Error("HTTP " + r.status);
12306 return r.json();
12307 })
12308 .then(function(data) {
12309 pollRetries = 0;
12310 if (data.state === "complete") {
12311 clearInterval(elapsedTimer);
12312 lcSetPhase("Done");
12313 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12314 } else if (data.state === "failed") {
12315 lcShowError(data.message);
12316 } else if (data.state === "cancelled") {
12317 lcShowCancelled();
12318 } else {
12319 var s = Math.floor((Date.now() - startTime) / 1000);
12320 if (s > 90 && !warnShown) {
12321 warnShown = true;
12322 var w = document.getElementById("lc-warn");
12323 if (w) w.classList.remove("hidden");
12324 }
12325 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
12326 setTimeout(function() { lcPoll(waitId); }, 1500);
12327 }
12328 })
12329 .catch(function() {
12330 pollRetries++;
12331 if (pollRetries >= 5) {
12332 lcShowError("Lost connection to server. Reload to check status.");
12333 } else {
12334 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12335 }
12336 });
12337 }
12338
12339 var params = new URLSearchParams(formData);
12340 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12341 .then(function(r) {
12342 var waitId = r.headers.get("x-wait-id");
12343 if (!waitId) { window.location.href = "/scan"; return; }
12344 activeWaitId = waitId;
12345 setTimeout(function() { lcPoll(waitId); }, 1500);
12346 })
12347 .catch(function(err) {
12348 lcShowError("Could not reach server: " + (err.message || err));
12349 });
12350 }
12351
12352 if (quickScanBtn) {
12353 quickScanBtn.addEventListener("click", function () {
12354 var pathVal = pathInput ? pathInput.value.trim() : "";
12355 if (!pathVal) {
12356 alert("Please enter or browse to a project path first.");
12357 return;
12358 }
12359 quickScanBtn.disabled = true;
12360 quickScanBtn.textContent = "Scanning...";
12361 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12362 startAsyncAnalysis(new FormData(form));
12363 });
12364 }
12365
12366 var mixedPolicyInfo = {
12367 code_only: {
12368 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.",
12369 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'
12370 },
12371 code_and_comment: {
12372 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.",
12373 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'
12374 },
12375 comment_only: {
12376 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.",
12377 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'
12378 },
12379 separate_mixed_category: {
12380 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.",
12381 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'
12382 }
12383 };
12384
12385 var scanPresetInfo = {
12386 balanced: {
12387 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.",
12388 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12389 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12390 note: "Best when you want a stable local overview before making deeper adjustments.",
12391 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12392 },
12393 code_focused: {
12394 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12395 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12396 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12397 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12398 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12399 },
12400 comment_audit: {
12401 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12402 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12403 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12404 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12405 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12406 },
12407 deep_review: {
12408 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12409 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12410 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12411 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12412 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12413 }
12414 };
12415
12416 var artifactPresetInfo = {
12417 review: {
12418 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.",
12419 chips: ["HTML", "PDF"],
12420 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12421 },
12422 full: {
12423 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.",
12424 chips: ["HTML", "PDF", "JSON"],
12425 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12426 },
12427 html_only: {
12428 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.",
12429 chips: ["HTML only", "Fast local review"],
12430 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12431 },
12432 machine: {
12433 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12434 chips: ["HTML", "JSON"],
12435 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12436 }
12437 };
12438
12439 function applyTheme(theme) {
12440 if (theme === "dark") document.body.classList.add("dark-theme");
12441 else document.body.classList.remove("dark-theme");
12442 }
12443
12444 function loadSavedTheme() {
12445 var saved = null;
12446 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12447 applyTheme(saved === "dark" ? "dark" : "light");
12448 }
12449
12450 function updateScrollProgress() {
12451 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12452 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12453 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12454 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12455 var step = Math.min(Math.max(currentStep, 1), 4);
12456 var base = stepBase[step];
12457 var end = stepEnd[step];
12458
12459 var scrollFrac = 0;
12460 var activePanel = document.querySelector(".wizard-step.active");
12461 if (activePanel) {
12462 var scrollTop = window.scrollY || window.pageYOffset || 0;
12463 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12464 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12465 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12466 var scrolled = scrollTop + viewH - panelTop;
12467 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12468 }
12469
12470 var percent = Math.round(base + (end - base) * scrollFrac);
12471 percent = Math.min(end, Math.max(base, percent));
12472 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12473 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12474 }
12475
12476 function updateWizardProgress() {
12477 updateScrollProgress();
12478 }
12479
12480 var stepDescriptions = [
12481 "Choose a project folder, apply scope filters, and preview which files will be counted.",
12482 "Configure how mixed code-plus-comment lines and docstrings are classified.",
12483 "Pick your output formats, scan preset, and where reports are saved.",
12484 "Review all settings and launch the analysis."
12485 ];
12486
12487 function updateStepNav(step) {
12488 var infoLabel = document.getElementById("step-nav-info-label");
12489 var infoDesc = document.getElementById("step-nav-info-desc");
12490 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12491 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
12492 }
12493
12494 function updateSidebarSummary() {
12495 var sumPath = document.getElementById("sum-path");
12496 var sumPreset = document.getElementById("sum-preset");
12497 var sumOutput = document.getElementById("sum-output");
12498 var sidebarSummary = document.getElementById("sidebar-summary");
12499 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12500 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
12501 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12502 if (sumPath) sumPath.textContent = pathVal || "—";
12503 if (sumPreset) sumPreset.textContent = presetVal || "—";
12504 if (sumOutput) sumOutput.textContent = outputVal || "—";
12505 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12506 }
12507
12508 function setStep(step, pushHistory) {
12509 currentStep = step;
12510 stepPanels.forEach(function (panel) {
12511 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12512 });
12513 stepButtons.forEach(function (button) {
12514 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12515 });
12516 var layoutEl = document.querySelector(".layout");
12517 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12518 updateWizardProgress();
12519 updateStepNav(step);
12520 stepButtons.forEach(function(btn) {
12521 var t = Number(btn.getAttribute("data-step-target"));
12522 btn.classList.toggle("done", t < step);
12523 });
12524 updateSidebarSummary();
12525
12526 if (pushHistory !== false) {
12527 try {
12528 history.pushState({ wizardStep: step }, "", "#step" + step);
12529 } catch (e) {}
12530 }
12531
12532 window.scrollTo({ top: 0, behavior: "instant" });
12533 }
12534
12535 window.addEventListener("popstate", function (e) {
12536 if (e.state && e.state.wizardStep) {
12537 setStep(e.state.wizardStep, false);
12538 } else {
12539 var hashMatch = location.hash.match(/^#step([1-4])$/);
12540 if (hashMatch) setStep(Number(hashMatch[1]), false);
12541 }
12542 });
12543
12544 function inferTitleFromPath(value) {
12545 if (!value) return "project";
12546 var cleaned = value.replace(/[\/\\]+$/, "");
12547 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12548 return parts.length ? parts[parts.length - 1] : value;
12549 }
12550
12551 function updateReportTitleFromPath() {
12552 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12553 if (!reportTitleTouched) {
12554 reportTitleInput.value = inferred;
12555 }
12556 var title = reportTitleInput.value || inferred;
12557 if (liveReportTitle) liveReportTitle.textContent = title;
12558 if (reportTitlePreview) reportTitlePreview.textContent = title;
12559 document.title = "OxideSLOC | " + title;
12560
12561 var projectPath = (pathInput.value || "").trim();
12562 if (navProjectPill && navProjectTitle) {
12563 if (projectPath.length > 0) {
12564 navProjectTitle.textContent = inferred;
12565 navProjectPill.classList.add("visible");
12566 } else {
12567 navProjectTitle.textContent = "";
12568 navProjectPill.classList.remove("visible");
12569 }
12570 }
12571 }
12572
12573 function updateMixedPolicyUI() {
12574 var key = mixedLinePolicy.value || "code_only";
12575 var info = mixedPolicyInfo[key];
12576 document.getElementById("mixed-policy-description").textContent = info.description;
12577 document.getElementById("mixed-policy-example").textContent = info.example;
12578 }
12579
12580 function updatePythonDocstringUI() {
12581 var checked = !!pythonDocstrings.checked;
12582 document.getElementById("python-docstring-example").textContent = checked
12583 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
12584 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
12585 document.getElementById("python-docstring-live-help").textContent = checked
12586 ? "Enabled: docstrings contribute to comment-style totals."
12587 : "Disabled: docstrings are not counted as comment content.";
12588 }
12589
12590 function renderPresetChips(targetId, chips) {
12591 var target = document.getElementById(targetId);
12592 if (!target) return;
12593 target.innerHTML = (chips || []).map(function (chip) {
12594 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12595 }).join('');
12596 }
12597
12598 function updatePresetDescriptions() {
12599 var scanInfo = scanPresetInfo[scanPreset.value];
12600 var artifactInfo = artifactPresetInfo[artifactPreset.value];
12601 document.getElementById("scan-preset-description").textContent = scanInfo.description;
12602 document.getElementById("scan-preset-example").textContent = scanInfo.example;
12603 document.getElementById("scan-preset-note").textContent = scanInfo.note;
12604 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12605 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12606 renderPresetChips("scan-preset-summary", scanInfo.chips);
12607 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12608 }
12609
12610 function applyScanPreset() {
12611 var info = scanPresetInfo[scanPreset.value];
12612 if (!info || !info.apply) return;
12613 mixedLinePolicy.value = info.apply.mixed;
12614 pythonDocstrings.checked = !!info.apply.docstrings;
12615 document.getElementById("generated_file_detection").value = info.apply.generated;
12616 document.getElementById("minified_file_detection").value = info.apply.minified;
12617 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12618 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12619 document.getElementById("binary_file_behavior").value = info.apply.binary;
12620 updateMixedPolicyUI();
12621 updatePythonDocstringUI();
12622 }
12623
12624 function applyArtifactPreset() {
12625 var enabled = { html: false, pdf: false };
12626 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12627 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12628 if (artifactPreset.value === "html_only") { enabled.html = true; }
12629 if (artifactPreset.value === "machine") { enabled.html = true; }
12630
12631 artifactCards.forEach(function (card) {
12632 var artifact = card.getAttribute("data-artifact");
12633 if (artifact === "json") return;
12634 var checked = !!enabled[artifact];
12635 var checkbox = card.querySelector(".artifact-checkbox");
12636 checkbox.checked = checked;
12637 card.classList.toggle("selected", checked);
12638 });
12639 }
12640
12641 function toggleArtifactCard(card) {
12642 var checkbox = card.querySelector(".artifact-checkbox");
12643 checkbox.checked = !checkbox.checked;
12644 card.classList.toggle("selected", checkbox.checked);
12645 }
12646
12647 function updateReview() {
12648 var scanSummary = document.getElementById("review-scan-summary");
12649 var countSummary = document.getElementById("review-count-summary");
12650 var artifactSummary = document.getElementById("review-artifact-summary");
12651 var outputSummary = document.getElementById("review-output-summary");
12652 var previewSummary = document.getElementById("review-preview-summary");
12653 var readinessSummary = document.getElementById("review-readiness-summary");
12654 var includeText = document.getElementById("include_globs").value.trim();
12655 var excludeText = document.getElementById("exclude_globs").value.trim();
12656 var sidePathPreview = document.getElementById("side-path-preview");
12657 var sideOutputPreview = document.getElementById("side-output-preview");
12658 var sideTitlePreview = document.getElementById("side-title-preview");
12659
12660 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12661 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12662 if (sideTitlePreview) {
12663 var rt = document.getElementById("report_title");
12664 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12665 }
12666
12667 scanSummary.innerHTML = ""
12668 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12669 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12670 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12671
12672 countSummary.innerHTML = ""
12673 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12674 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12675 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12676 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12677 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12678 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12679 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12680 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12681
12682 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12683 artifactSummary.innerHTML = ""
12684 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12685 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12686
12687 outputSummary.innerHTML = ""
12688 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12689 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12690
12691 if (previewSummary) {
12692 if (GIT_MODE) {
12693 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>';
12694 } else {
12695 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12696 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12697 var statMap = {};
12698 statButtons.forEach(function (button) {
12699 var valueNode = button.querySelector('.scope-stat-value');
12700 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12701 });
12702 previewSummary.innerHTML = ''
12703 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12704 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12705 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12706 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12707 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12708 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12709
12710 if (readinessSummary) {
12711 var selectedArtifactsCount = selectedArtifacts.length;
12712 readinessSummary.innerHTML = ''
12713 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12714 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12715 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12716 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12717 }
12718 } // end else (non-GIT_MODE)
12719 }
12720 }
12721
12722 function escapeHtml(value) {
12723 return String(value)
12724 .replace(/&/g, "&")
12725 .replace(/</g, "<")
12726 .replace(/>/g, ">")
12727 .replace(/"/g, """)
12728 .replace(/'/g, "'");
12729 }
12730
12731 function isPythonVisible() {
12732 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
12733 }
12734
12735 function syncPythonVisibility() {
12736 var html = previewPanel.textContent || "";
12737 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
12738 pythonWraps.forEach(function (node) {
12739 node.classList.toggle("hidden", !hasPython);
12740 });
12741 }
12742
12743 function attachPreviewInteractions() {
12744 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
12745 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
12746 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
12747 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
12748 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
12749 var searchInput = previewPanel.querySelector("#explorer-search");
12750 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
12751 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
12752 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
12753 var activeFilter = "all";
12754 var activeLanguage = "";
12755 var searchTerm = "";
12756 var currentSortKey = null;
12757 var currentSortOrder = "asc";
12758 var childRows = {};
12759
12760 rows.forEach(function (row) {
12761 var parentId = row.getAttribute("data-parent-id") || "";
12762 var rowId = row.getAttribute("data-row-id") || "";
12763 if (!childRows[parentId]) childRows[parentId] = [];
12764 childRows[parentId].push(rowId);
12765 });
12766
12767 function rowById(id) {
12768 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
12769 }
12770
12771 function hasCollapsedAncestor(row) {
12772 var parentId = row.getAttribute("data-parent-id");
12773 while (parentId) {
12774 var parent = rowById(parentId);
12775 if (!parent) break;
12776 if (parent.getAttribute("data-expanded") === "false") return true;
12777 parentId = parent.getAttribute("data-parent-id");
12778 }
12779 return false;
12780 }
12781
12782 function updateToggleGlyph(row) {
12783 var toggle = row.querySelector(".tree-toggle");
12784 if (!toggle) return;
12785 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
12786 }
12787
12788 function rowSortValue(row, key) {
12789 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
12790 }
12791
12792 function updateSortButtons() {
12793 sortButtons.forEach(function (button) {
12794 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
12795 var indicator = button.querySelector(".tree-sort-indicator");
12796 button.classList.toggle("active", isActive);
12797 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
12798 if (indicator) {
12799 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
12800 }
12801 });
12802 }
12803
12804 function sortSiblingRows() {
12805 if (!treeContainer) {
12806 updateSortButtons();
12807 return;
12808 }
12809
12810 var rowMap = {};
12811 var childrenMap = {};
12812 rows.forEach(function (row) {
12813 var rowId = row.getAttribute("data-row-id");
12814 var parentId = row.getAttribute("data-parent-id") || "";
12815 rowMap[rowId] = row;
12816 if (!childrenMap[parentId]) childrenMap[parentId] = [];
12817 childrenMap[parentId].push(rowId);
12818 });
12819
12820 Object.keys(childrenMap).forEach(function (parentId) {
12821 if (!parentId) return;
12822 childrenMap[parentId].sort(function (a, b) {
12823 var rowA = rowMap[a];
12824 var rowB = rowMap[b];
12825 if (!currentSortKey) {
12826 return Number(a) - Number(b);
12827 }
12828 var valueA = rowSortValue(rowA, currentSortKey);
12829 var valueB = rowSortValue(rowB, currentSortKey);
12830 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
12831 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
12832 var fallbackA = rowSortValue(rowA, "name");
12833 var fallbackB = rowSortValue(rowB, "name");
12834 if (fallbackA < fallbackB) return -1;
12835 if (fallbackA > fallbackB) return 1;
12836 return Number(a) - Number(b);
12837 });
12838 });
12839
12840 var orderedIds = [];
12841 function pushChildren(parentId) {
12842 (childrenMap[parentId] || []).forEach(function (childId) {
12843 orderedIds.push(childId);
12844 pushChildren(childId);
12845 });
12846 }
12847
12848 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
12849 orderedIds.push(topId);
12850 pushChildren(topId);
12851 });
12852
12853 orderedIds.forEach(function (id) {
12854 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
12855 });
12856 updateSortButtons();
12857 }
12858
12859 function updateLanguageButtons() {
12860 languageButtons.forEach(function (button) {
12861 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
12862 var isActive = languageValue === activeLanguage;
12863 button.classList.toggle("active", isActive);
12864 });
12865 }
12866
12867 function rowSelfMatches(row) {
12868 var kind = row.getAttribute("data-kind");
12869 var status = row.getAttribute("data-status");
12870 var language = (row.getAttribute("data-language") || "").toLowerCase();
12871 var name = row.getAttribute("data-name-lower") || "";
12872 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
12873 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
12874 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
12875 var passesLanguage = !activeLanguage || language === activeLanguage;
12876 return passesFilter && passesSearch && passesLanguage;
12877 }
12878
12879 function hasMatchingDescendant(rowId) {
12880 return (childRows[rowId] || []).some(function (childId) {
12881 var childRow = rowById(childId);
12882 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
12883 });
12884 }
12885
12886 function rowMatches(row) {
12887 if (rowSelfMatches(row)) return true;
12888 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
12889 }
12890
12891 function resetViewState() {
12892 activeFilter = "all";
12893 activeLanguage = "";
12894 searchTerm = "";
12895 currentSortKey = null;
12896 currentSortOrder = "asc";
12897 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12898 if (searchInput) searchInput.value = "";
12899 if (filterSelect) filterSelect.value = "all";
12900 updateLanguageButtons();
12901 }
12902
12903 function applyVisibility() {
12904 rows.forEach(function (row) {
12905 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
12906 row.classList.toggle("hidden-by-filter", !visible);
12907 row.style.display = visible ? "grid" : "none";
12908 });
12909 buttons.forEach(function (button) {
12910 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
12911 });
12912 if (filterSelect) filterSelect.value = activeFilter;
12913 }
12914
12915 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
12916 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
12917 var originalStats = {};
12918 buttons.forEach(function (btn) {
12919 var f = btn.getAttribute('data-filter');
12920 var v = btn.querySelector('.scope-stat-value');
12921 if (f && v) originalStats[f] = v.textContent;
12922 });
12923
12924 function applySubmoduleStats(statsJson) {
12925 try {
12926 var s = JSON.parse(statsJson);
12927 buttons.forEach(function (btn) {
12928 var f = btn.getAttribute('data-filter');
12929 var v = btn.querySelector('.scope-stat-value');
12930 if (!v) return;
12931 if (f === 'dir') v.textContent = s.dirs;
12932 else if (f === 'file') v.textContent = s.files;
12933 else if (f === 'supported') v.textContent = s.supported;
12934 else if (f === 'skipped') v.textContent = s.skipped;
12935 else if (f === 'unsupported') v.textContent = s.unsupported;
12936 });
12937 } catch (e) {}
12938 }
12939
12940 function restoreBaseRepoStats() {
12941 buttons.forEach(function (btn) {
12942 var f = btn.getAttribute('data-filter');
12943 var v = btn.querySelector('.scope-stat-value');
12944 if (v && originalStats[f]) v.textContent = originalStats[f];
12945 });
12946 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12947 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
12948 }
12949
12950 submoduleChips.forEach(function (chip) {
12951 chip.addEventListener('click', function () {
12952 var statsJson = chip.getAttribute('data-sub-stats');
12953 if (!statsJson) return;
12954 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12955 chip.classList.add('active');
12956 applySubmoduleStats(statsJson);
12957 if (baseRepoBtn) baseRepoBtn.style.display = '';
12958 });
12959 });
12960
12961 if (baseRepoBtn) {
12962 baseRepoBtn.addEventListener('click', function () {
12963 restoreBaseRepoStats();
12964 resetViewState();
12965 sortSiblingRows();
12966 applyVisibility();
12967 });
12968 }
12969
12970 buttons.forEach(function (button) {
12971 button.addEventListener("click", function () {
12972 var filterValue = button.getAttribute("data-filter") || "all";
12973 if (filterValue === "reset-view") {
12974 restoreBaseRepoStats();
12975 resetViewState();
12976 sortSiblingRows();
12977 applyVisibility();
12978 return;
12979 }
12980 activeFilter = filterValue;
12981 applyVisibility();
12982 });
12983 });
12984
12985 rows.forEach(function (row) {
12986 updateToggleGlyph(row);
12987 var toggle = row.querySelector(".tree-toggle");
12988 if (toggle) {
12989 toggle.addEventListener("click", function () {
12990 var expanded = row.getAttribute("data-expanded") !== "false";
12991 row.setAttribute("data-expanded", expanded ? "false" : "true");
12992 updateToggleGlyph(row);
12993 applyVisibility();
12994 });
12995 }
12996 });
12997
12998 actionButtons.forEach(function (button) {
12999 button.addEventListener("click", function () {
13000 var action = button.getAttribute("data-explorer-action");
13001 if (action === "expand-all") {
13002 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13003 } else if (action === "collapse-all") {
13004 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13005 } else if (action === "clear-filters") {
13006 resetViewState();
13007 }
13008 sortSiblingRows();
13009 applyVisibility();
13010 });
13011 });
13012
13013 if (filterSelect) {
13014 filterSelect.addEventListener("change", function () {
13015 activeFilter = filterSelect.value || "all";
13016 applyVisibility();
13017 });
13018 }
13019
13020 languageButtons.forEach(function (button) {
13021 button.addEventListener("click", function () {
13022 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13023 updateLanguageButtons();
13024 applyVisibility();
13025 });
13026 });
13027
13028 sortButtons.forEach(function (button) {
13029 button.addEventListener("click", function () {
13030 var sortKey = button.getAttribute("data-sort-key");
13031 if (currentSortKey === sortKey) {
13032 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13033 } else {
13034 currentSortKey = sortKey;
13035 currentSortOrder = "asc";
13036 }
13037 sortSiblingRows();
13038 applyVisibility();
13039 });
13040 });
13041
13042 if (searchInput) {
13043 searchInput.addEventListener("input", function () {
13044 searchTerm = searchInput.value.trim().toLowerCase();
13045 applyVisibility();
13046 });
13047 }
13048
13049 updateLanguageButtons();
13050 sortSiblingRows();
13051 applyVisibility();
13052 }
13053
13054 function loadPreview() {
13055 if (!previewPanel || !pathInput) return;
13056 if (GIT_MODE) {
13057 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>';
13058 return;
13059 }
13060 var path = pathInput.value.trim();
13061 var zeroWarn = document.getElementById('zero-files-warning');
13062 if (!path) {
13063 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13064 if (zeroWarn) zeroWarn.style.display = 'none';
13065 return;
13066 }
13067 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13068 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13069 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
13070 var previewUrl = "/preview?path=" + encodeURIComponent(path)
13071 + "&include_globs=" + encodeURIComponent(includeValue)
13072 + "&exclude_globs=" + encodeURIComponent(excludeValue);
13073 fetch(previewUrl)
13074 .then(function (response) { return response.text(); })
13075 .then(function (html) {
13076 previewPanel.innerHTML = html;
13077 attachPreviewInteractions();
13078 syncPythonVisibility();
13079 updateReview();
13080 setTimeout(collapseLanguagePills, 50);
13081 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13082 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13083 var sizeText = document.getElementById('project-size-text');
13084 var sizeBtn = document.getElementById('project-size-btn');
13085 // In server mode with upload sizes available, keep the compressed/original pair.
13086 if (SERVER_MODE && window._lastUploadSizes) {
13087 var us = window._lastUploadSizes;
13088 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13089 ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13090 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13091 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13092 } else if (sizeText && projectSize) {
13093 sizeText.textContent = 'Project size: ' + projectSize;
13094 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13095 } else if (sizeText) {
13096 sizeText.textContent = 'Project size: —';
13097 }
13098 if (zeroWarn) {
13099 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13100 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13101 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13102 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13103 if (supportedCount === 0 && fileCount > 0) {
13104 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).';
13105 zeroWarn.style.display = '';
13106 } else {
13107 zeroWarn.style.display = 'none';
13108 }
13109 }
13110 })
13111 .catch(function (err) {
13112 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13113 });
13114 }
13115
13116 function pickDirectory(targetInput, kind) {
13117 if (SERVER_MODE) {
13118 if (kind === 'output') {
13119 showBannerToast(
13120 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13121 false,
13122 { top: true, icon: '📁' }
13123 );
13124 return;
13125 }
13126 var inputEl = kind === 'coverage'
13127 ? document.getElementById('cov-upload-input')
13128 : document.getElementById('dir-upload-input');
13129 if (!inputEl) return;
13130 inputEl.onchange = function () {
13131 var files = inputEl.files;
13132 if (!files || files.length === 0) return;
13133 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13134 if (browseBtn) browseBtn.disabled = true;
13135
13136 function fileToBase64(file) {
13137 return new Promise(function (resolve, reject) {
13138 var reader = new FileReader();
13139 reader.onload = function () {
13140 var b64 = reader.result.split(',')[1];
13141 resolve(b64);
13142 };
13143 reader.onerror = reject;
13144 reader.readAsDataURL(file);
13145 });
13146 }
13147
13148 if (kind === 'coverage') {
13149 var f = files[0];
13150 if (previewPanel && targetInput === pathInput)
13151 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13152 fileToBase64(f).then(function (b64) {
13153 return fetch('/api/upload-file', {
13154 method: 'POST',
13155 headers: { 'Content-Type': 'application/json' },
13156 body: JSON.stringify({ filename: f.name, content: b64 })
13157 }).then(function (r) { return r.json(); });
13158 })
13159 .then(function (d) {
13160 if (d && d.tmp_path) {
13161 if (coverageInput) coverageInput.value = d.tmp_path;
13162 setCovStatus('idle');
13163 } else if (d && d.error) { showBannerToast(d.error, true); }
13164 })
13165 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13166 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13167 } else {
13168 // ── Filter to source-code files only ─────────────────────────
13169 // Binary, generated, and dependency files (node_modules, .git,
13170 // build artifacts) are skipped so they are never uploaded.
13171 var CODE_EXTS = new Set([
13172 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13173 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13174 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13175 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13176 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13177 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13178 'tf','hcl','proto','thrift','avsc','graphql','gql'
13179 ]);
13180 var codeFiles = [];
13181 for (var i = 0; i < files.length; i++) {
13182 var f = files[i];
13183 var name = f.name;
13184 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13185 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13186 codeFiles.push(f); continue;
13187 }
13188 var dot = name.lastIndexOf('.');
13189 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13190 }
13191 // Collect specific .git metadata files for server-side git detection.
13192 // These have no source extension so they are excluded by the loop above,
13193 // but the server needs them to read branch/commit/author without running git.
13194 var gitMetaFiles = [];
13195 for (var i = 0; i < files.length; i++) {
13196 var f = files[i];
13197 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13198 var gitIdx = rp.indexOf('/.git/');
13199 if (gitIdx < 0) continue;
13200 var gitRel = rp.slice(gitIdx + 1);
13201 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13202 gitRel === '.git/logs/HEAD' ||
13203 gitRel.startsWith('.git/refs/heads/') ||
13204 gitRel.startsWith('.git/refs/tags/')) {
13205 gitMetaFiles.push(f);
13206 }
13207 }
13208 var uploadFiles = codeFiles.concat(gitMetaFiles);
13209 var total = files.length;
13210 var kept = codeFiles.length;
13211 if (kept === 0) {
13212 if (previewPanel && targetInput === pathInput)
13213 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13214 if (browseBtn) browseBtn.disabled = false;
13215 inputEl.value = '';
13216 return;
13217 }
13218
13219 // ── Helper: apply upload result to UI ────────────────────────
13220 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13221 function applyUploadResult(tmpPath, sizes) {
13222 targetInput.value = tmpPath;
13223 scrollInputToEnd(targetInput);
13224 if (sizes && SERVER_MODE) {
13225 window._lastUploadSizes = sizes;
13226 // Immediately show both sizes before preview loads.
13227 var sizeText = document.getElementById('project-size-text');
13228 var sizeBtn = document.getElementById('project-size-btn');
13229 if (sizeText) {
13230 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13231 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13232 }
13233 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13234 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13235 }
13236 if (targetInput === pathInput) {
13237 updateReportTitleFromPath();
13238 autoSetOutputDir(tmpPath);
13239 fetchProjectHistory(tmpPath);
13240 loadPreview();
13241 suggestCoverageFile(tmpPath);
13242 }
13243 updateReview();
13244 if (browseBtn) browseBtn.disabled = false;
13245 inputEl.value = '';
13246 }
13247
13248 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13249 if (typeof CompressionStream !== 'undefined') {
13250 if (previewPanel && targetInput === pathInput)
13251 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13252
13253 // Build a minimal POSIX ustar tar header for a single file entry.
13254 function buildUstarHeader(filePath, fileSize) {
13255 var BLOCK = 512;
13256 var hdr = new Uint8Array(BLOCK);
13257 var enc = new TextEncoder();
13258 function wStr(off, len, s) {
13259 var b = enc.encode(s);
13260 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13261 }
13262 function wOct(off, len, val) {
13263 var s = val.toString(8);
13264 while (s.length < len - 1) s = '0' + s;
13265 wStr(off, len, s + '\0');
13266 }
13267 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13268 var name = filePath, prefix = '';
13269 if (filePath.length > 99) {
13270 var split = filePath.lastIndexOf('/', 154);
13271 if (split > 0 && filePath.length - split - 1 <= 99) {
13272 prefix = filePath.substring(0, split);
13273 name = filePath.substring(split + 1);
13274 } else { name = filePath.substring(0, 99); }
13275 }
13276 wStr(0, 100, name); // name
13277 wOct(100, 8, 0o000644); // mode
13278 wOct(108, 8, 0); // uid
13279 wOct(116, 8, 0); // gid
13280 wOct(124, 12, fileSize); // size
13281 wOct(136, 12, 0); // mtime (epoch)
13282 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13283 hdr[156] = 48; // type flag '0' = regular file
13284 wStr(157, 100, ''); // linkname
13285 wStr(257, 6, 'ustar'); // magic
13286 wStr(263, 2, '00'); // version
13287 wStr(265, 32, ''); // uname
13288 wStr(297, 32, ''); // gname
13289 wOct(329, 8, 0); // devmajor
13290 wOct(337, 8, 0); // devminor
13291 wStr(345, 155, prefix); // prefix
13292 // Compute checksum (sum of all bytes, placeholder = 32).
13293 var chk = 0;
13294 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13295 var cs = chk.toString(8);
13296 while (cs.length < 6) cs = '0' + cs;
13297 wStr(148, 8, cs + '\0 ');
13298 return hdr;
13299 }
13300
13301 // Build tar.gz one file at a time, piping through CompressionStream.
13302 // RAM usage = compressed output buffer + one file at a time.
13303 (async function () {
13304 try {
13305 var BLOCK = 512;
13306 var cs = new CompressionStream('gzip');
13307 var writer = cs.writable.getWriter();
13308 var chunks = [];
13309 var reader = cs.readable.getReader();
13310 var collecting = (async function () {
13311 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13312 })();
13313
13314 for (var i = 0; i < uploadFiles.length; i++) {
13315 var file = uploadFiles[i];
13316 var path = file.webkitRelativePath || file.name;
13317 var buf = await file.arrayBuffer();
13318 var data = new Uint8Array(buf);
13319 // Header block
13320 await writer.write(buildUstarHeader(path, data.length));
13321 // Data padded to 512-byte boundary
13322 if (data.length > 0) {
13323 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13324 var block = new Uint8Array(padded);
13325 block.set(data);
13326 await writer.write(block);
13327 }
13328 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13329 if (previewPanel && targetInput === pathInput)
13330 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13331 }
13332 }
13333 // End-of-archive: two 512-byte zero blocks
13334 await writer.write(new Uint8Array(BLOCK * 2));
13335 await writer.close();
13336 await collecting;
13337
13338 var blob = new Blob(chunks, { type: 'application/gzip' });
13339 var sizeMB = (blob.size / 1048576).toFixed(1);
13340 if (previewPanel && targetInput === pathInput)
13341 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13342
13343 var resp = await fetch('/api/upload-tarball', {
13344 method: 'POST',
13345 headers: { 'Content-Type': 'application/gzip' },
13346 body: blob
13347 });
13348 var d = await resp.json();
13349 if (d && d.tmp_path) {
13350 applyUploadResult(d.tmp_path, {
13351 compressed_bytes: d.compressed_bytes || 0,
13352 original_bytes: d.original_bytes || 0
13353 });
13354 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13355 } catch (e) {
13356 showBannerToast('Upload failed: ' + String(e), true);
13357 if (browseBtn) browseBtn.disabled = false;
13358 inputEl.value = '';
13359 }
13360 })();
13361
13362 } else {
13363 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13364 // Used only on browsers that lack CompressionStream (pre-2023).
13365 var BATCH = 200;
13366 var batches = [];
13367 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13368 var totalBatches = batches.length;
13369 if (previewPanel && targetInput === pathInput)
13370 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13371
13372 function sendBatch(idx, currentUploadId, lastTmpPath) {
13373 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13374 if (previewPanel && targetInput === pathInput && totalBatches > 1)
13375 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13376 Promise.all(batches[idx].map(function (file) {
13377 return fileToBase64(file).then(function (b64) {
13378 return { path: file.webkitRelativePath || file.name, content: b64 };
13379 });
13380 })).then(function (fileList) {
13381 var body = { files: fileList };
13382 if (currentUploadId) body.upload_id = currentUploadId;
13383 return fetch('/api/upload-directory', {
13384 method: 'POST', headers: { 'Content-Type': 'application/json' },
13385 body: JSON.stringify(body)
13386 }).then(function (r) { return r.json(); });
13387 }).then(function (d) {
13388 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13389 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13390 }).catch(function (e) {
13391 showBannerToast('Upload failed: ' + String(e), true);
13392 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13393 });
13394 }
13395 sendBatch(0, null, '');
13396 }
13397 }
13398 };
13399 inputEl.click();
13400 return;
13401 }
13402
13403 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13404 if (browseButton) browseButton.disabled = true;
13405
13406 if (previewPanel && targetInput === pathInput) {
13407 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13408 }
13409
13410 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
13411 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13412 .then(function (data) {
13413 if (data && data.selected_path) {
13414 targetInput.value = data.selected_path;
13415 scrollInputToEnd(targetInput);
13416
13417 if (targetInput === pathInput) {
13418 updateReportTitleFromPath();
13419 autoSetOutputDir(data.selected_path);
13420 fetchProjectHistory(data.selected_path);
13421 loadPreview();
13422 suggestCoverageFile(data.selected_path);
13423 }
13424
13425 updateReview();
13426 } else if (targetInput === pathInput) {
13427 loadPreview();
13428 }
13429 })
13430 .catch(function () {
13431 window.alert("Directory picker request failed.");
13432 if (previewPanel && targetInput === pathInput) {
13433 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13434 }
13435 })
13436 .finally(function () {
13437 if (browseButton) browseButton.disabled = false;
13438 });
13439 }
13440
13441 if (themeToggle) {
13442 themeToggle.addEventListener("click", function () {
13443 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13444 applyTheme(nextTheme);
13445 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13446 });
13447 }
13448
13449 stepButtons.forEach(function (button) {
13450 button.addEventListener("click", function () {
13451 setStep(Number(button.getAttribute("data-step-target")));
13452 });
13453 });
13454
13455 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13456 button.addEventListener("click", function () {
13457 setStep(Number(button.getAttribute("data-step-target")) || 1);
13458 });
13459 });
13460
13461 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13462 button.addEventListener("click", function () {
13463 updateReview();
13464 setStep(Number(button.getAttribute("data-next")));
13465 });
13466 });
13467
13468 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13469 button.addEventListener("click", function () {
13470 setStep(Number(button.getAttribute("data-prev")));
13471 });
13472 });
13473
13474 document.addEventListener("keydown", function (e) {
13475 var tag = (document.activeElement || {}).tagName || "";
13476 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13477 if (e.altKey || e.ctrlKey || e.metaKey) return;
13478 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13479 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13480 });
13481
13482 if (useSamplePath) {
13483 useSamplePath.addEventListener("click", function () {
13484 pathInput.value = "tests/fixtures/basic";
13485 updateReportTitleFromPath();
13486 autoSetOutputDir("tests/fixtures/basic");
13487 loadPreview();
13488 suggestCoverageFile("tests/fixtures/basic");
13489 });
13490 }
13491
13492 if (useDefaultOutput) {
13493 useDefaultOutput.addEventListener("click", function () {
13494 delete outputDirInput.dataset.userEdited;
13495 autoSetOutputDir(pathInput ? pathInput.value : "");
13496 updateReview();
13497 });
13498 }
13499
13500 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13501 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13502
13503 // ── Drag-and-drop directory upload (server mode only) ─────────────────
13504 // Dropping a folder onto the path field bypasses Chrome's
13505 // "Upload X files to this site?" confirmation dialog.
13506 async function readDirRecursively(dirEntry, basePath) {
13507 var reader = dirEntry.createReader();
13508 var all = [];
13509 for (;;) {
13510 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13511 if (!batch.length) break;
13512 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13513 }
13514 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13515 var out = [];
13516 for (var i = 0; i < all.length; i++) {
13517 var sub = all[i];
13518 if (sub.isFile) {
13519 var f = await new Promise(function(res) { sub.file(res); });
13520 out.push({ file: f, path: basePath + '/' + sub.name });
13521 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13522 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13523 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13524 }
13525 }
13526 return out;
13527 }
13528
13529 function setupPathDropZone() {
13530 if (!SERVER_MODE || !pathInput) return;
13531 var CODE_EXTS = new Set([
13532 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13533 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13534 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13535 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13536 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13537 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13538 ]);
13539 pathInput.addEventListener('dragover', function(e) {
13540 e.preventDefault();
13541 pathInput.classList.add('drag-over');
13542 });
13543 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13544 pathInput.addEventListener('drop', function(e) {
13545 e.preventDefault();
13546 pathInput.classList.remove('drag-over');
13547 var items = e.dataTransfer.items;
13548 if (!items || !items.length) return;
13549 var dirEntry = null;
13550 for (var i = 0; i < items.length; i++) {
13551 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13552 if (entry && entry.isDirectory) { dirEntry = entry; break; }
13553 }
13554 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13555 var btn = browsePath;
13556 if (btn) btn.disabled = true;
13557 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13558
13559 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13560 var total = allEntries.length;
13561 var codeEntries = allEntries.filter(function(e) {
13562 var n = e.file.name;
13563 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13564 var dot = n.lastIndexOf('.');
13565 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13566 });
13567 var kept = codeEntries.length;
13568 if (kept === 0) {
13569 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13570 if (btn) btn.disabled = false; return;
13571 }
13572
13573 function finish(tmpPath, sizes) {
13574 pathInput.value = tmpPath;
13575 scrollInputToEnd(pathInput);
13576 if (sizes) {
13577 window._lastUploadSizes = sizes;
13578 var sizeText = document.getElementById('project-size-text');
13579 var sizeBtn = document.getElementById('project-size-btn');
13580 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13581 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13582 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13583 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13584 }
13585 updateReportTitleFromPath();
13586 autoSetOutputDir(tmpPath);
13587 fetchProjectHistory(tmpPath);
13588 loadPreview();
13589 suggestCoverageFile(tmpPath);
13590 updateReview();
13591 if (btn) btn.disabled = false;
13592 }
13593
13594 if (typeof CompressionStream === 'undefined') {
13595 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13596 if (btn) btn.disabled = false; return;
13597 }
13598
13599 try {
13600 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13601 var BLOCK = 512;
13602 var cs = new CompressionStream('gzip');
13603 var wtr = cs.writable.getWriter();
13604 var chunks = [];
13605 var rdr = cs.readable.getReader();
13606 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13607
13608 function buildHdr(fp, sz) {
13609 var hdr = new Uint8Array(BLOCK);
13610 var enc = new TextEncoder();
13611 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]; }
13612 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13613 var nm = fp, pfx = '';
13614 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); } }
13615 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13616 for (var i = 148; i < 156; i++) hdr[i] = 32;
13617 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);
13618 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13619 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13620 return hdr;
13621 }
13622
13623 for (var i = 0; i < codeEntries.length; i++) {
13624 var ce = codeEntries[i];
13625 var buf = await ce.file.arrayBuffer();
13626 var data = new Uint8Array(buf);
13627 await wtr.write(buildHdr(ce.path, data.length));
13628 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13629 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13630 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13631 }
13632 await wtr.write(new Uint8Array(BLOCK * 2));
13633 await wtr.close();
13634 await collecting;
13635
13636 var blob = new Blob(chunks, { type: 'application/gzip' });
13637 var sizeMB = (blob.size / 1048576).toFixed(1);
13638 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13639 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13640 var d = await resp.json();
13641 if (d && d.tmp_path) {
13642 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13643 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13644 } catch (err) {
13645 showBannerToast('Upload failed: ' + String(err), true);
13646 if (btn) btn.disabled = false;
13647 }
13648 }).catch(function(err) {
13649 showBannerToast('Could not read folder: ' + String(err), true);
13650 if (btn) btn.disabled = false;
13651 });
13652 });
13653 }
13654 setupPathDropZone();
13655 if (browseCoverage) {
13656 browseCoverage.addEventListener("click", function () {
13657 pickDirectory(coverageInput || pathInput, "coverage");
13658 });
13659 }
13660
13661 function setCovStatus(state, opts) {
13662 if (!covScanStatus) return;
13663 opts = opts || {};
13664 covScanStatus.className = "cov-scan-status cov-scan-" + state;
13665 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13666 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>';
13667 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>';
13668 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>';
13669 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>';
13670 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13671 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13672 if (state === "scanning") {
13673 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13674 } else if (state === "found") {
13675 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13676 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13677 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13678 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13679 } else if (state === "hint") {
13680 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13681 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
13682 html += '<div class="cov-scan-sub">Generate one with:</div>';
13683 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13684 } else if (state === "none") {
13685 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13686 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
13687 }
13688 html += '</div></div>';
13689 covScanStatus.innerHTML = html;
13690 if (state === "found") {
13691 var useBtn = covScanStatus.querySelector(".cov-scan-use");
13692 if (useBtn) useBtn.addEventListener("click", function () {
13693 if (coverageInput) coverageInput.value = "";
13694 covAutoFilled = false;
13695 setCovStatus("idle");
13696 });
13697 }
13698 }
13699
13700 function suggestCoverageFile(projectPath) {
13701 if (!coverageInput || !covScanStatus) return;
13702 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13703 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
13704 clearTimeout(coverageSuggestTimer);
13705 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
13706 setCovStatus("scanning");
13707 coverageSuggestTimer = setTimeout(function () {
13708 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
13709 .then(function (r) { return r.json(); })
13710 .then(function (d) {
13711 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13712 if (!d) { setCovStatus("none"); return; }
13713 if (d.found) {
13714 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
13715 setCovStatus("found", { found: d.found, tool: d.tool });
13716 } else if (d.tool && d.hint) {
13717 setCovStatus("hint", { tool: d.tool, hint: d.hint });
13718 } else {
13719 setCovStatus("none");
13720 }
13721 })
13722 .catch(function () { setCovStatus("idle"); });
13723 }, 600);
13724 }
13725
13726 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
13727
13728 if (coverageInput) coverageInput.addEventListener("input", function () {
13729 covAutoFilled = false;
13730 if (!this.value.trim()) setCovStatus("idle");
13731 });
13732
13733 // ── Language pill overflow: collapse to "+N more" chip ─────────────
13734 function collapseLanguagePills() {
13735 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
13736 rows.forEach(function(row) {
13737 // Remove any previous overflow chip
13738 var prev = row.querySelector('.lang-overflow-chip');
13739 if (prev) prev.remove();
13740 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
13741 pills.forEach(function(p) { p.style.display = ''; });
13742 if (!pills.length) return;
13743
13744 // Measure after restoring all pills
13745 var containerRight = row.getBoundingClientRect().right;
13746 var hidden = [];
13747 for (var i = pills.length - 1; i >= 1; i--) {
13748 var rect = pills[i].getBoundingClientRect();
13749 if (rect.right > containerRight + 2) {
13750 hidden.unshift(pills[i]);
13751 pills[i].style.display = 'none';
13752 } else {
13753 break;
13754 }
13755 }
13756
13757 if (hidden.length) {
13758 var chip = document.createElement('button');
13759 chip.type = 'button';
13760 chip.className = 'language-pill lang-overflow-chip';
13761 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
13762 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
13763 row.appendChild(chip);
13764 }
13765 });
13766 }
13767
13768 // Run after preview loads (preview panel populates language pills)
13769 var _origLoadPreviewCb = window.__previewLoaded;
13770 document.addEventListener('previewLoaded', collapseLanguagePills);
13771 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
13772 setTimeout(collapseLanguagePills, 400);
13773
13774 // ── Project history & output dir auto-set ──────────────────────────
13775 var wsOutputRoot = document.getElementById("ws-output-root");
13776 var wsScanCount = document.getElementById("ws-scan-count");
13777 var wsLastScan = document.getElementById("ws-last-scan");
13778 var historyBadge = document.getElementById("path-history-badge");
13779 var historyTimer = null;
13780
13781 var wsOutputLink = document.getElementById("ws-output-link");
13782 function syncStripOutputRoot() {
13783 var val = outputDirInput ? outputDirInput.value : "";
13784 var display = val || "project/sloc";
13785 if (wsOutputRoot) wsOutputRoot.textContent = display;
13786 if (wsOutputLink) wsOutputLink.dataset.folder = val;
13787 }
13788
13789 function scrollInputToEnd(input) {
13790 if (!input) return;
13791 // Defer so the DOM has the new value before we measure scroll width.
13792 requestAnimationFrame(function () {
13793 input.scrollLeft = input.scrollWidth;
13794 input.selectionStart = input.selectionEnd = input.value.length;
13795 });
13796 }
13797
13798 function autoSetOutputDir(projectPath) {
13799 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
13800 if (GIT_MODE && GIT_OUTPUT_DIR) {
13801 outputDirInput.value = GIT_OUTPUT_DIR;
13802 scrollInputToEnd(outputDirInput);
13803 syncStripOutputRoot();
13804 updateReview();
13805 return;
13806 }
13807 if (!projectPath || !projectPath.trim()) return;
13808 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
13809 outputDirInput.value = cleaned + "/sloc";
13810 scrollInputToEnd(outputDirInput);
13811 syncStripOutputRoot();
13812 updateReview();
13813 }
13814
13815 var wsBranch = document.getElementById("ws-branch");
13816
13817 function fetchProjectHistory(projectPath) {
13818 if (!projectPath || !projectPath.trim()) {
13819 if (wsScanCount) wsScanCount.textContent = "—";
13820 if (wsLastScan) wsLastScan.textContent = "—";
13821 if (wsBranch) wsBranch.textContent = "—";
13822 if (historyBadge) historyBadge.style.display = "none";
13823 return;
13824 }
13825 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
13826 .then(function (r) { return r.ok ? r.json() : null; })
13827 .then(function (data) {
13828 if (!data) return;
13829 var countStr = data.scan_count > 0
13830 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
13831 : "never";
13832 var tsStr = data.last_scan_timestamp
13833 ? data.last_scan_timestamp.replace(" UTC","")
13834 : "—";
13835 if (wsScanCount) wsScanCount.textContent = countStr;
13836 if (wsLastScan) wsLastScan.textContent = tsStr;
13837 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
13838 if (data.scan_count > 0) {
13839 if (historyBadge) {
13840 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
13841 historyBadge.textContent = data.scan_count + " previous scan" +
13842 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
13843 "Last: " + (data.last_scan_timestamp || "—") +
13844 " — " + (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.";
13845 historyBadge.className = "path-history-badge found";
13846 historyBadge.style.display = "";
13847 }
13848 } else {
13849 if (historyBadge) historyBadge.style.display = "none";
13850 }
13851 })
13852 .catch(function () {});
13853 }
13854
13855 function onPathChange() {
13856 var val = pathInput ? pathInput.value : "";
13857 // Discard stale upload sizes when the user edits the path manually.
13858 window._lastUploadSizes = null;
13859 updateReportTitleFromPath();
13860 autoSetOutputDir(val);
13861 updateSidebarSummary();
13862 clearTimeout(historyTimer);
13863 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
13864 if (previewTimer) clearTimeout(previewTimer);
13865 previewTimer = setTimeout(loadPreview, 280);
13866 suggestCoverageFile(val);
13867 }
13868
13869 if (pathInput) {
13870 pathInput.addEventListener("input", onPathChange);
13871 }
13872
13873 if (outputDirInput) {
13874 outputDirInput.addEventListener("input", function () {
13875 outputDirInput.dataset.userEdited = "1";
13876 syncStripOutputRoot();
13877 updateReview();
13878 });
13879 }
13880
13881 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
13882 if (!node) return;
13883 node.addEventListener("input", function () {
13884 updateReview();
13885 if (previewTimer) clearTimeout(previewTimer);
13886 previewTimer = setTimeout(loadPreview, 280);
13887 });
13888 });
13889
13890 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
13891 var node = document.getElementById(id);
13892 if (node) node.addEventListener("change", updateReview);
13893 });
13894
13895 if (reportTitleInput) {
13896 reportTitleInput.addEventListener("input", function () {
13897 reportTitleTouched = reportTitleInput.value.trim().length > 0;
13898 updateReportTitleFromPath();
13899 updateReview();
13900 });
13901 }
13902
13903 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
13904 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
13905 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
13906 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
13907
13908 artifactCards.forEach(function (card) {
13909 card.addEventListener("click", function () {
13910 if (card.classList.contains("artifact-locked")) return;
13911 toggleArtifactCard(card);
13912 updateReview();
13913 });
13914 });
13915
13916 if (coverageInput) {
13917 coverageInput.addEventListener("input", function () {
13918 if (coverageInput.value.trim()) setCovStatus("idle");
13919 });
13920 }
13921
13922 if (form && loading && submitButton) {
13923 form.addEventListener("submit", function (e) {
13924 e.preventDefault();
13925 submitButton.disabled = true;
13926 submitButton.textContent = "Scanning...";
13927 startAsyncAnalysis(new FormData(form));
13928 });
13929 }
13930
13931 function openPath(folder) {
13932 if (!folder) return;
13933 fetch('/open-path?path=' + encodeURIComponent(folder))
13934 .then(function (r) { return r.json(); })
13935 .then(function (d) {
13936 if (d && d.server_mode_disabled)
13937 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
13938 })
13939 .catch(function () {});
13940 }
13941
13942 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
13943 btn.addEventListener('click', function () {
13944 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
13945 });
13946 });
13947
13948 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
13949 if (wsOutputLink) {
13950 wsOutputLink.addEventListener('click', function () {
13951 openPath(wsOutputLink.dataset.folder || '');
13952 });
13953 }
13954
13955 loadSavedTheme();
13956 updateMixedPolicyUI();
13957 updatePythonDocstringUI();
13958 applyScanPreset();
13959 updatePresetDescriptions();
13960 applyArtifactPreset();
13961 updateReview();
13962 updateScrollProgress(); // initialise bar to 0% (step 1)
13963 window.addEventListener("scroll", updateScrollProgress, { passive: true });
13964 onPathChange(); // seed output dir, history badge, and preview from initial path
13965 loadPreview();
13966 updateStepNav(1);
13967
13968 // Restore step from URL hash on initial load (e.g., back-forward cache)
13969 (function() {
13970 var hashMatch = location.hash.match(/^#step([1-4])$/);
13971 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
13972 })();
13973
13974 (function randomizeWatermarks() {
13975 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
13976 if (!wms.length) return;
13977 var placed = [];
13978 function tooClose(top, left) {
13979 for (var i = 0; i < placed.length; i++) {
13980 var dt = Math.abs(placed[i][0] - top);
13981 var dl = Math.abs(placed[i][1] - left);
13982 if (dt < 16 && dl < 12) return true;
13983 }
13984 return false;
13985 }
13986 function pick(leftBand) {
13987 for (var attempt = 0; attempt < 50; attempt++) {
13988 var top = Math.random() * 88 + 2;
13989 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13990 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
13991 }
13992 var top = Math.random() * 88 + 2;
13993 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13994 placed.push([top, left]);
13995 return [top, left];
13996 }
13997 var half = Math.floor(wms.length / 2);
13998 wms.forEach(function (img, i) {
13999 var pos = pick(i < half);
14000 var size = Math.floor(Math.random() * 80 + 110);
14001 var rot = (Math.random() * 360).toFixed(1);
14002 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14003 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;
14004 });
14005 })();
14006
14007 (function spawnCodeParticles() {
14008 var container = document.getElementById('code-particles');
14009 if (!container) return;
14010 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'];
14011 for (var i = 0; i < 38; i++) {
14012 (function(idx) {
14013 var el = document.createElement('span');
14014 el.className = 'code-particle';
14015 el.textContent = snippets[idx % snippets.length];
14016 var left = Math.random() * 94 + 2;
14017 var top = Math.random() * 88 + 6;
14018 var dur = (Math.random() * 10 + 9).toFixed(1);
14019 var delay = (Math.random() * 18).toFixed(1);
14020 var rot = (Math.random() * 26 - 13).toFixed(1);
14021 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14022 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';
14023 container.appendChild(el);
14024 })(i);
14025 }
14026 })();
14027 })();
14028 </script>
14029 <script nonce="{{ csp_nonce }}">
14030 (function () {
14031 var raw = {{ prefill_json|safe }};
14032 if (!raw || typeof raw !== 'object' || !raw.path) return;
14033 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14034 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14035 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14036 setVal('path-input', raw.path || '');
14037 setVal('include-globs', raw.include_globs || '');
14038 setVal('exclude-globs', raw.exclude_globs || '');
14039 setVal('output-dir', raw.output_dir || '');
14040 setVal('report-title', raw.report_title || '');
14041 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14042 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14043 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14044 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14045 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14046 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14047 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14048 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14049 setChecked('generate-html', raw.generate_html !== false);
14050 setChecked('generate-pdf', !!raw.generate_pdf);
14051 // Trigger dynamic UI updates after pre-fill.
14052 setTimeout(function () {
14053 var pathEl = document.getElementById('path-input');
14054 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14055 var policyEl = document.getElementById('mixed-line-policy');
14056 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14057 }, 80);
14058 })();
14059 </script>
14060 <script nonce="{{ csp_nonce }}">
14061 (function(){
14062 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'}];
14063 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);});}
14064 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14065 function init(){
14066 var btn=document.getElementById('settings-btn');if(!btn)return;
14067 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14068 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>';
14069 document.body.appendChild(m);
14070 var g=document.getElementById('scheme-grid');
14071 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);});
14072 var cl=document.getElementById('settings-close');
14073 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);
14074 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');});
14075 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14076 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14077 }
14078 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14079 }());
14080 </script>
14081 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14082 <div class="wb-ftip-arrow"></div>
14083 <span id="wb-ftip-text"></span>
14084 </div>
14085 <script nonce="{{ csp_nonce }}">(function(){
14086 var tip=document.getElementById('wb-ftip');
14087 var txt=document.getElementById('wb-ftip-text');
14088 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14089 if(!tip||!txt)return;
14090 function pos(el){
14091 var r=el.getBoundingClientRect();
14092 tip.style.display='block';
14093 var tw=tip.offsetWidth;
14094 var lx=r.left+r.width/2-tw/2;
14095 if(lx<8)lx=8;
14096 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14097 tip.style.left=lx+'px';
14098 tip.style.top=(r.bottom+8)+'px';
14099 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';}
14100 }
14101 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14102 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14103 el.addEventListener('mouseleave',function(){tip.style.display='none';});
14104 });
14105 })();
14106 (function(){
14107 function fixArtifactHintSpacing(){
14108 var grid=document.querySelector('.artifact-grid');
14109 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14110 }
14111 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14112 }());
14113 (function(){
14114 var dot=document.getElementById('status-dot');
14115 var pingEl=document.getElementById('server-ping-ms');
14116 var tipEl=document.getElementById('server-tip-ping');
14117 var fm=document.getElementById('footer-mode');
14118 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)';}}
14119 function doPing(){
14120 var t0=performance.now();
14121 fetch('/healthz',{cache:'no-store'})
14122 .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);})
14123 .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)';}});
14124 }
14125 doPing();
14126 setInterval(doPing,5000);
14127 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');}
14128 })();
14129 </script>
14130 <footer class="site-footer">
14131 local code analysis - metrics, history and reports
14132 · <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>
14133 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14134 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14135 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14136 · <a href="/api-docs" rel="noopener">REST API</a>
14137 </footer>
14138</body>
14139</html>
14140"##,
14141 ext = "html"
14142)]
14143struct IndexTemplate {
14144 version: &'static str,
14145 prefill_json: String,
14146 csp_nonce: String,
14147 git_repo: String,
14148 git_ref: String,
14149 git_label_json: String,
14150 git_output_dir_json: String,
14151 server_mode: bool,
14152}
14153
14154#[derive(Template)]
14157#[template(
14158 source = r##"
14159<!doctype html>
14160<html lang="en">
14161<head>
14162 <meta charset="utf-8">
14163 <meta name="viewport" content="width=device-width, initial-scale=1">
14164 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14165 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14166 <style nonce="{{ csp_nonce }}">
14167 :root {
14168 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14169 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14170 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14171 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14172 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14173 }
14174 body.dark-theme {
14175 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14176 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14177 }
14178 *{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;}
14179 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14180 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14181 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14182 .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;}
14183 @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));}}
14184 .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);}
14185 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14186 .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));}
14187 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14188 .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;}
14189 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14190 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14191 @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; } }
14192 .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;}
14193 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14194 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14195 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14196 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14197 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14198 .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;}
14199 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14200 .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);}
14201 .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;}
14202 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14203 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14204 .settings-modal-body{padding:14px 16px 16px;}
14205 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14206 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14207 .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;}
14208 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14209 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14210 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14211 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14212 .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;}
14213 .tz-select:focus{border-color:var(--oxide);}
14214 .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;}
14215 .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;}
14216 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14217 .hero{text-align:center;margin:0 auto 18px;}
14218 .hero-logo-wrap{display:inline-block;cursor:default;}
14219 .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;}
14220 .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;}
14221 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14222 .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;}
14223 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%);}
14224 .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;
14225 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14226 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14227 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;}
14228 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14229 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14230 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;}
14231 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14232 .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;}
14233 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14234 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14235 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14236 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14237 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14238 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14239 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14240 .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;}
14241 .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;}
14242 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14243 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14244 .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);}
14245 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14246 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14247 .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);}
14248 .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);}
14249 .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);}
14250 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14251 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14252 .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;}
14253 body.dark-theme .action-card-cta{color:var(--oxide);}
14254 .action-card.view .action-card-cta{color:var(--accent-2);}
14255 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14256 .action-card.compare .action-card-cta{color:#7c3aed;}
14257 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14258 .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);}
14259 .action-card.git-tools .action-card-cta{color:#15803d;}
14260 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14261 .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);}
14262 .action-card.trend .action-card-cta{color:#0e7490;}
14263 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14264 .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);}
14265 .action-card.automation .action-card-cta{color:#b45309;}
14266 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14267 .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);}
14268 .action-card.test-metrics .action-card-cta{color:#be185d;}
14269 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14270 .action-card:hover .action-card-cta{gap:12px;}
14271 .action-card.card-split{flex-direction:row;align-items:stretch;}
14272 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14273 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14274 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14275 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14276 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14277 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14278 .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;}
14279 .ac-badge.active{opacity:1;}
14280 .ac-badge.github{border-color:#555;color:#555;}
14281 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14282 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14283 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14284 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14285 body.dark-theme .ac-right-row{color:var(--muted);}
14286 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14287 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14288 .divider{height:1px;background:var(--line);margin:32px 0;}
14289 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14290 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14291 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14292 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14293 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14294 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14295 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14296 body.dark-theme .info-chip-val{color:var(--oxide);}
14297 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14298 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14299 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14300 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14301 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14302 border:6px solid transparent;border-top-color:var(--text);}
14303 .info-chip:hover .info-chip-tip{display:block;}
14304 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14305 .chip-slide.fading{filter:blur(5px);opacity:0;}
14306 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14307 .site-footer a{color:var(--muted);}
14308 .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;}
14309 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14310 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14311 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14312 .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;}
14313 .lan-badge.local{background:var(--oxide-2);}
14314 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14315 .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);}
14316 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14317 .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;}
14318 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14319 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14320 .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;}
14321 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14322 .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;}
14323 .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);}
14324 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14325 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14326 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14327 .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;}
14328 @media (max-height: 1100px) {
14329 .page{padding-top:10px;}
14330 .hero{margin-bottom:10px;}
14331 .hero-logo{width:54px;height:60px;}
14332 .hero-logo-shadow{width:42px;}
14333 .hero-title{font-size:28px;}
14334 .hero-subtitle{font-size:13px;}
14335 .card-sections{gap:16px;margin-bottom:10px;}
14336 .card-section-grid-2,.card-section-grid-3{gap:10px;}
14337 .action-card{padding:8px 15px 8px;}
14338 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14339 .action-card-icon svg{width:18px;height:18px;}
14340 .action-card-title{font-size:13px;}
14341 .action-card-desc{font-size:11px;margin-bottom:6px;}
14342 .action-card-cta{font-size:11px;}
14343 .ac-right-row{font-size:11px;}
14344 .divider{margin:14px 0;}
14345 .info-strip{gap:7px;margin-bottom:12px;}
14346 .info-chip{padding:7px 10px;}
14347 .info-chip-val{font-size:13px;}
14348 .info-chip-label{font-size:9px;}
14349 .site-footer{padding:8px 24px;font-size:12px;}
14350 }
14351 @media (max-height: 850px) {
14352 .page{padding-top:6px;}
14353 .hero{margin-bottom:6px;}
14354 .hero-logo{width:42px;height:46px;}
14355 .hero-title{font-size:22px;}
14356 .hero-subtitle{font-size:12px;}
14357 .card-sections{gap:10px;}
14358 .action-card-desc{margin-bottom:4px;}
14359 .divider{margin:8px 0;}
14360 .info-strip{margin-bottom:6px;}
14361 .lan-local-hint{margin-top:10px;}
14362 }
14363 </style>
14364</head>
14365<body>
14366 <div class="background-watermarks" aria-hidden="true">
14367 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14368 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14369 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14370 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14371 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14372 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14373 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14374 </div>
14375 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14376 <div class="top-nav">
14377 <div class="top-nav-inner">
14378 <a class="brand" href="/">
14379 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14380 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14381 </a>
14382 <div class="nav-right">
14383 <a class="nav-pill" href="/">Home</a>
14384 <div class="nav-dropdown">
14385 <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>
14386 <div class="nav-dropdown-menu">
14387 <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>
14388 </div>
14389 </div>
14390 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14391 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14392 <div class="nav-dropdown">
14393 <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>
14394 <div class="nav-dropdown-menu">
14395 <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>
14396 </div>
14397 </div>
14398 <div class="server-status-wrap" id="server-status-wrap">
14399 <div class="nav-pill server-online-pill" id="server-status-pill">
14400 <span class="status-dot" id="status-dot"></span>
14401 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14402 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14403 </div>
14404 <div class="server-status-tip">
14405 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14406 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14407 </div>
14408 </div>
14409 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14410 <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>
14411 </button>
14412 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14413 <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>
14414 <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>
14415 </button>
14416 </div>
14417 </div>
14418 </div>
14419
14420 <div class="page">
14421 <div class="hero">
14422 <div class="hero-logo-wrap" id="hero-logo-wrap">
14423 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14424 </div>
14425 <div class="hero-logo-shadow"></div>
14426 <div class="hero-title-wrap">
14427 <div class="hero-title-aura" aria-hidden="true"></div>
14428 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14429 </div>
14430 <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>
14431 </div>
14432
14433 <div class="card-sections">
14434
14435 <div>
14436 <div class="card-section-label">Analysis</div>
14437 <div class="card-section-grid-2">
14438 <a class="action-card scan card-split" href="/scan-setup">
14439 <div class="action-card-left">
14440 <div class="action-card-icon">
14441 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14442 </div>
14443 <div class="action-card-title">Scan Project</div>
14444 <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>
14445 <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>
14446 </div>
14447 <div class="action-card-sep"></div>
14448 <div class="action-card-right">
14449 <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>
14450 <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>
14451 <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>
14452 <div class="ac-right-stat" id="acp-scan-stat"></div>
14453 </div>
14454 </a>
14455 <a class="action-card test-metrics card-split" href="/test-metrics">
14456 <div class="action-card-left">
14457 <div class="action-card-icon">
14458 <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>
14459 </div>
14460 <div class="action-card-title">Test Metrics</div>
14461 <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>
14462 <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>
14463 </div>
14464 <div class="action-card-sep"></div>
14465 <div class="action-card-right">
14466 <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>
14467 <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>
14468 <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>
14469 <div class="ac-right-stat" id="acp-test-stat"></div>
14470 </div>
14471 </a>
14472 </div>
14473 </div>
14474
14475 <div>
14476 <div class="card-section-label">Reports & Insights</div>
14477 <div class="card-section-grid-3">
14478 <a class="action-card view" href="/view-reports">
14479 <div class="action-card-icon">
14480 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14481 </div>
14482 <div class="action-card-title">View Reports</div>
14483 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14484 <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>
14485 </a>
14486 <a class="action-card compare" href="/compare-scans">
14487 <div class="action-card-icon">
14488 <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>
14489 </div>
14490 <div class="action-card-title">Compare Scans</div>
14491 <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>
14492 <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>
14493 </a>
14494 <a class="action-card trend" href="/trend-reports">
14495 <div class="action-card-icon">
14496 <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>
14497 </div>
14498 <div class="action-card-title">Trend Report</div>
14499 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14500 <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>
14501 </a>
14502 </div>
14503 </div>
14504
14505 <div>
14506 <div class="card-section-label">Developer Tools</div>
14507 <div class="card-section-grid-2">
14508 <a class="action-card git-tools card-split" href="/git-browser">
14509 <div class="action-card-left">
14510 <div class="action-card-icon">
14511 <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>
14512 </div>
14513 <div class="action-card-title">Git Browser</div>
14514 <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>
14515 <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>
14516 </div>
14517 <div class="action-card-sep"></div>
14518 <div class="action-card-right">
14519 <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>
14520 <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>
14521 <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>
14522 </div>
14523 </a>
14524 <a class="action-card automation card-split" href="/integrations">
14525 <div class="action-card-left">
14526 <div class="action-card-icon">
14527 <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>
14528 </div>
14529 <div class="action-card-title">Integrations</div>
14530 <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>
14531 <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>
14532 </div>
14533 <div class="action-card-sep"></div>
14534 <div class="action-card-right">
14535 <div class="ac-badges-grid">
14536 <span class="ac-badge github" id="acp-gh">GitHub</span>
14537 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
14538 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
14539 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14540 </div>
14541 <div class="ac-right-stat" id="acp-int-stat"></div>
14542 </div>
14543 </a>
14544 </div>
14545 </div>
14546
14547 </div>
14548
14549 {% if server_mode %}
14550 <div class="lan-card server">
14551 <div class="lan-card-header">
14552 <span class="lan-badge">LAN server</span>
14553 Accessible on your network
14554 </div>
14555 {% if let Some(ip) = lan_ip %}
14556 <div class="lan-url-row">
14557 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14558 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14559 <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>
14560 Copy URL
14561 </button>
14562 </div>
14563 <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>
14564 {% if has_api_key %}
14565 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
14566 {% endif %}
14567 {% else %}
14568 <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>
14569 {% endif %}
14570 </div>
14571 {% endif %}
14572
14573 <div class="divider"></div>
14574
14575 <div class="info-strip">
14576 <div class="info-chip">
14577 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14578 <div class="chip-slide">
14579 <div class="info-chip-val">41</div>
14580 <div class="info-chip-label">Languages</div>
14581 </div>
14582 </div>
14583 <div class="info-chip">
14584 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14585 <div class="chip-slide">
14586 <div class="info-chip-val">100%</div>
14587 <div class="info-chip-label">Self-contained</div>
14588 </div>
14589 </div>
14590 <div class="info-chip">
14591 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14592 <div class="chip-slide">
14593 <div class="info-chip-val">HTML+PDF</div>
14594 <div class="info-chip-label">Exportable reports</div>
14595 </div>
14596 </div>
14597 <div class="info-chip">
14598 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14599 <div class="chip-slide">
14600 <div class="info-chip-val">Webhook</div>
14601 <div class="info-chip-label">3 platforms</div>
14602 </div>
14603 </div>
14604 <div class="info-chip">
14605 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14606 <div class="chip-slide">
14607 <div class="info-chip-val">IEEE</div>
14608 <div class="info-chip-label">1045-1992</div>
14609 </div>
14610 </div>
14611 </div>
14612
14613 {% if lan_ip.is_none() %}
14614 <div class="lan-local-hint">
14615 <strong>Want teammates on the same network to access this?</strong><br>
14616 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
14617 </div>
14618 {% endif %}
14619 </div>
14620
14621 <footer class="site-footer">
14622 local code analysis - metrics, history and reports
14623 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
14624 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14625 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14626 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14627 · <a href="/api-docs" rel="noopener">REST API</a>
14628 </footer>
14629
14630 <script nonce="{{ csp_nonce }}">
14631 (function () {
14632 var storageKey = 'oxide-sloc-theme';
14633 var body = document.body;
14634 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14635 var toggle = document.getElementById('theme-toggle');
14636 if (toggle) toggle.addEventListener('click', function () {
14637 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14638 body.classList.toggle('dark-theme', next === 'dark');
14639 try { localStorage.setItem(storageKey, next); } catch(e) {}
14640 });
14641 var copyBtn = document.getElementById('lan-copy-btn');
14642 if (copyBtn) copyBtn.addEventListener('click', function() {
14643 var btn = this;
14644 var el = document.getElementById('lan-url-val');
14645 if (!el) return;
14646 var url = el.textContent.trim();
14647 if (navigator.clipboard) {
14648 navigator.clipboard.writeText(url).then(function() {
14649 var orig = btn.innerHTML;
14650 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!';
14651 setTimeout(function() { btn.innerHTML = orig; }, 1800);
14652 });
14653 }
14654 });
14655 (function randomizeWatermarks() {
14656 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14657 if (!wms.length) return;
14658 var placed = [];
14659 function tooClose(top, left) {
14660 for (var i = 0; i < placed.length; i++) {
14661 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14662 if (dt < 16 && dl < 12) return true;
14663 }
14664 return false;
14665 }
14666 function pick(leftBand) {
14667 for (var attempt = 0; attempt < 50; attempt++) {
14668 var top = Math.random() * 88 + 2;
14669 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14670 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14671 }
14672 var top = Math.random() * 88 + 2;
14673 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14674 placed.push([top, left]); return [top, left];
14675 }
14676 var half = Math.floor(wms.length / 2);
14677 wms.forEach(function (img, i) {
14678 var pos = pick(i < half);
14679 var size = Math.floor(Math.random() * 100 + 120);
14680 var rot = (Math.random() * 360).toFixed(1);
14681 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14682 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;
14683 });
14684 })();
14685
14686 (function spawnCodeParticles() {
14687 var container = document.getElementById('code-particles');
14688 if (!container) return;
14689 var snippets = [
14690 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14691 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14692 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14693 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14694 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14695 ];
14696 var count = 38;
14697 for (var i = 0; i < count; i++) {
14698 (function(idx) {
14699 var el = document.createElement('span');
14700 el.className = 'code-particle';
14701 var text = snippets[idx % snippets.length];
14702 el.textContent = text;
14703 var left = Math.random() * 94 + 2;
14704 var top = Math.random() * 88 + 6;
14705 var dur = (Math.random() * 10 + 9).toFixed(1);
14706 var delay = (Math.random() * 18).toFixed(1);
14707 var rot = (Math.random() * 26 - 13).toFixed(1);
14708 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14709 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
14710 + '--rot:' + rot + 'deg;--op:' + op + ';'
14711 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
14712 container.appendChild(el);
14713 })(i);
14714 }
14715 })();
14716 (function heroAnimations() {
14717 var sub = document.getElementById('hero-subtitle');
14718 if (sub) {
14719 var full = sub.textContent.trim();
14720 sub.textContent = '';
14721 sub.style.opacity = '1';
14722 var cursor = document.createElement('span');
14723 cursor.className = 'hero-cursor';
14724 sub.appendChild(cursor);
14725 var i = 0;
14726 setTimeout(function() {
14727 var iv = setInterval(function() {
14728 if (i < full.length) {
14729 sub.insertBefore(document.createTextNode(full[i]), cursor);
14730 i++;
14731 } else {
14732 clearInterval(iv);
14733 setTimeout(function() {
14734 cursor.style.transition = 'opacity 1s ease';
14735 cursor.style.opacity = '0';
14736 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
14737 }, 2400);
14738 }
14739 }, 11);
14740 }, 374);
14741 }
14742 })();
14743 (function logoBob() {
14744 var logo = document.querySelector('.hero-logo');
14745 var shadow = document.querySelector('.hero-logo-shadow');
14746 if (!logo) return;
14747 var cycleStart = null, cycleDur = 3600;
14748 var peakY = -14, peakScale = 1.07, peakRot = 0;
14749 function newCycle() {
14750 cycleDur = 3000 + Math.random() * 1840;
14751 peakY = -(9 + Math.random() * 13.8);
14752 peakScale = 1.04 + Math.random() * 0.081;
14753 peakRot = (Math.random() * 11.5 - 5.75);
14754 }
14755 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
14756 newCycle();
14757 function frame(ts) {
14758 if (cycleStart === null) cycleStart = ts;
14759 var t = (ts - cycleStart) / cycleDur;
14760 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
14761 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
14762 var y = peakY * phase;
14763 var sc = 1 + (peakScale - 1) * phase;
14764 var rot = peakRot * Math.sin(Math.PI * phase);
14765 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
14766 if (shadow) {
14767 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
14768 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
14769 }
14770 requestAnimationFrame(frame);
14771 }
14772 requestAnimationFrame(frame);
14773 })();
14774 (function mouseEffects() {
14775 var heroTitle = document.getElementById('hero-title');
14776 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
14777 function tick() {
14778 raf = null;
14779 if (heroTitle) {
14780 var r = heroTitle.getBoundingClientRect();
14781 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
14782 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
14783 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
14784 }
14785 }
14786 document.addEventListener('mousemove', function(e) {
14787 mx = e.clientX; my = e.clientY;
14788 if (!raf) raf = requestAnimationFrame(tick);
14789 });
14790 document.addEventListener('mouseleave', function() {
14791 if (heroTitle) {
14792 heroTitle.style.transition = 'transform 0.5s ease';
14793 heroTitle.style.transform = '';
14794 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
14795 }
14796 });
14797 document.querySelectorAll('.action-card').forEach(function(card) {
14798 card.addEventListener('mousemove', function(e) {
14799 var rect = card.getBoundingClientRect();
14800 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
14801 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
14802 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
14803 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
14804 });
14805 card.addEventListener('mouseleave', function() {
14806 card.style.transition = '';
14807 card.style.transform = '';
14808 });
14809 });
14810 })();
14811 (function chipSlideshow() {
14812 var slides = [
14813 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
14814 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
14815 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
14816 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
14817 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
14818 ];
14819 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
14820 var indices = [0,0,0,0,0];
14821 var paused = [false,false,false,false,false];
14822 chips.forEach(function(chip, i) {
14823 chip.addEventListener('mouseenter', function() { paused[i] = true; });
14824 chip.addEventListener('mouseleave', function() { paused[i] = false; });
14825 });
14826 function advance(i) {
14827 if (paused[i]) return;
14828 var chip = chips[i];
14829 var inner = chip.querySelector('.chip-slide');
14830 if (!inner) return;
14831 inner.classList.add('fading');
14832 setTimeout(function() {
14833 indices[i] = (indices[i] + 1) % slides[i].length;
14834 var s = slides[i][indices[i]];
14835 chip.querySelector('.info-chip-val').textContent = s.v;
14836 chip.querySelector('.info-chip-label').textContent = s.l;
14837 inner.classList.remove('fading');
14838 }, 720);
14839 }
14840 setInterval(function() {
14841 chips.forEach(function(chip, i) { advance(i); });
14842 }, 6000);
14843 })();
14844 (function cardLiveData() {
14845 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
14846 var el = document.getElementById('acp-scan-stat');
14847 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
14848 }).catch(function(){});
14849 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
14850 var el = document.getElementById('acp-test-stat');
14851 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
14852 }).catch(function(){});
14853 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
14854 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
14855 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
14856 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
14857 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
14858 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
14859 var stat = document.getElementById('acp-int-stat');
14860 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
14861 }).catch(function(){});
14862 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
14863 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
14864 }).catch(function(){});
14865 })();
14866 })();
14867 </script>
14868 <script nonce="{{ csp_nonce }}">
14869 (function(){
14870 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'}];
14871 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);});}
14872 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14873 function init(){
14874 var btn=document.getElementById('settings-btn');if(!btn)return;
14875 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14876 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>';
14877 document.body.appendChild(m);
14878 var g=document.getElementById('scheme-grid');
14879 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);});
14880 var cl=document.getElementById('settings-close');
14881 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);
14882 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');});
14883 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14884 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14885 }
14886 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14887 }());
14888 </script>
14889 <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>
14890</body>
14891</html>
14892"##,
14893 ext = "html"
14894)]
14895struct SplashTemplate {
14896 csp_nonce: String,
14897 server_mode: bool,
14898 lan_ip: Option<String>,
14899 port: u16,
14900 version: &'static str,
14901 has_api_key: bool,
14902}
14903
14904#[derive(Template)]
14907#[template(
14908 source = r##"
14909<!doctype html>
14910<html lang="en">
14911<head>
14912 <meta charset="utf-8">
14913 <meta name="viewport" content="width=device-width, initial-scale=1">
14914 <title>OxideSLOC — Start a Scan</title>
14915 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14916 <style nonce="{{ csp_nonce }}">
14917 :root {
14918 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
14919 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14920 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14921 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14922 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14923 }
14924 body.dark-theme {
14925 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14926 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14927 }
14928 *{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;}
14929 .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);}
14930 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14931 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
14932 .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));}
14933 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
14934 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14935 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14936 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14937 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14938 @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; } }
14939 .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;}
14940 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14941 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14942 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14943 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14944 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14945 .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;}
14946 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14947 .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);}
14948 .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;}
14949 .settings-close:hover{color:var(--text);background:var(--surface-2);}
14950 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14951 .settings-modal-body{padding:14px 16px 16px;}
14952 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14953 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14954 .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;}
14955 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14956 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14957 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14958 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14959 .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;}
14960 .tz-select:focus{border-color:var(--oxide);}
14961 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
14962 .page-header{text-align:center;margin-bottom:16px;}
14963 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
14964 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
14965 /* Cards */
14966 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
14967 .option-card-wrap{position:relative;}
14968 .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;}
14969 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
14970 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14971 .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;}
14972 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
14973 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
14974 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
14975 .card-top-row{display:flex;align-items:center;gap:20px;}
14976 /* Two-column layout inside each card */
14977 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
14978 .card-left{display:flex;align-items:flex-start;min-width:0;}
14979 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
14980 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
14981 .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);}
14982 .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);}
14983 .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);}
14984 .card-text{min-width:0;}
14985 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
14986 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
14987 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
14988 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
14989 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
14990 /* Right CTA column */
14991 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
14992 .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;}
14993 /* Re-scan count badge */
14994 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
14995 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
14996 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
14997 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
14998 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
14999 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15000 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15001 body.dark-theme .btn-secondary{color:var(--oxide);}
15002 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15003 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15004 /* File input overlay — must be full-width so it aligns with other card-right buttons */
15005 .file-input-wrap{position:relative;width:100%;}
15006 .file-input-wrap .btn{width:100%;}
15007 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15008 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15009 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15010 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15011 .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;}
15012 @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));}}
15013 /* Recent list (card 3 — full-width section below header) */
15014 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15015 .recent-list{display:flex;flex-direction:column;gap:8px;}
15016 .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;}
15017 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15018 .recent-item-info{flex:1;min-width:0;}
15019 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15020 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15021 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15022 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15023 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15024 .site-footer a{color:var(--muted);}
15025 @media(max-width:680px){
15026 .card-body{grid-template-columns:1fr;}
15027 .card-right{flex-direction:row;flex-wrap:wrap;}
15028 .btn{flex:1;}
15029 }
15030 .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;}
15031 .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;}
15032 .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;}
15033 </style>
15034</head>
15035<body>
15036 <div class="background-watermarks" aria-hidden="true">
15037 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15038 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15039 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15040 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15041 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15044 </div>
15045 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15046 <div class="top-nav">
15047 <div class="top-nav-inner">
15048 <a class="brand" href="/">
15049 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15050 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15051 </a>
15052 <div class="nav-right">
15053 <a class="nav-pill" href="/">Home</a>
15054 <div class="nav-dropdown">
15055 <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>
15056 <div class="nav-dropdown-menu">
15057 <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>
15058 </div>
15059 </div>
15060 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15061 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15062 <div class="nav-dropdown">
15063 <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>
15064 <div class="nav-dropdown-menu">
15065 <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>
15066 </div>
15067 </div>
15068 <div class="server-status-wrap" id="server-status-wrap">
15069 <div class="nav-pill server-online-pill" id="server-status-pill">
15070 <span class="status-dot" id="status-dot"></span>
15071 <span id="server-status-label">Server</span>
15072 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15073 </div>
15074 <div class="server-status-tip">
15075 OxideSLOC is running — accessible on your network.
15076 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15077 </div>
15078 </div>
15079 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15080 <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>
15081 </button>
15082 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15083 <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>
15084 <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>
15085 </button>
15086 </div>
15087 </div>
15088 </div>
15089
15090 <div class="page">
15091 <div class="page-header">
15092 <h1>How would you like to scan?</h1>
15093 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15094 </div>
15095
15096 <div class="option-grid">
15097
15098 <!-- Option 1: New scan -->
15099 <div class="option-card-wrap">
15100 <div class="option-card">
15101 <div class="option-icon new-scan">
15102 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15103 </div>
15104 <div class="card-body">
15105 <div class="card-left">
15106 <div class="card-text">
15107 <div class="option-title">Start a new scan</div>
15108 <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>
15109 <ul class="feature-list">
15110 <li>Live project scope preview before you run</li>
15111 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15112 <li>HTML, PDF, and JSON output — your choice</li>
15113 </ul>
15114 </div>
15115 </div>
15116 <div class="card-right">
15117 <a class="btn btn-primary" href="/scan">
15118 Configure & scan
15119 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15120 </a>
15121 <p class="card-tip">Full 4-step setup · all options</p>
15122 </div>
15123 </div>
15124 </div>
15125 </div>
15126
15127 <!-- Option 2: Load from config file -->
15128 <div class="option-card-wrap">
15129 <div class="option-card">
15130 <div class="option-icon load-config">
15131 <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>
15132 </div>
15133 <div class="card-body">
15134 <div class="card-left">
15135 <div class="card-text">
15136 <div class="option-title">Load a saved config</div>
15137 <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>
15138 <ul class="feature-list">
15139 <li>All 15 settings restored from the file</li>
15140 <li>Fully editable — change path or output dir</li>
15141 <li>Works with any scan-config.json</li>
15142 </ul>
15143 </div>
15144 </div>
15145 <div class="card-right">
15146 <div class="file-input-wrap">
15147 <button class="btn btn-secondary" id="load-config-btn" type="button">
15148 <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>
15149 Choose config file
15150 </button>
15151 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15152 </div>
15153 <p class="card-tip" id="config-file-name">Exported after every scan</p>
15154 </div>
15155 </div>
15156 </div>
15157 </div>
15158
15159 <!-- Option 3: Re-scan recent project -->
15160 <div class="option-card-wrap">
15161 <div class="option-card" id="recent-card">
15162 <div class="card-top-row">
15163 <div class="option-icon rescan">
15164 <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>
15165 </div>
15166 <div class="card-body">
15167 <div class="card-left">
15168 <div class="card-text">
15169 <div class="option-title">Re-scan a recent project</div>
15170 <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>
15171 <ul class="feature-list">
15172 <li>All 15+ settings restored from the saved config</li>
15173 <li>Path and output dir are editable before running</li>
15174 <li>Only scans with a saved config appear here</li>
15175 </ul>
15176 </div>
15177 </div>
15178 <div class="card-right">
15179 <div class="rescan-count-box">
15180 <div class="rescan-count-num" id="rescan-count-num">—</div>
15181 <div class="rescan-count-label">saved configs</div>
15182 </div>
15183 <a class="btn btn-secondary" href="/view-reports">
15184 <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>
15185 View all runs
15186 </a>
15187 <p class="card-tip">Opens run history</p>
15188 </div>
15189 </div>
15190 </div>
15191 <div class="section-divider"></div>
15192 <div class="recent-list" id="recent-list">
15193 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15194 </div>
15195 </div>
15196 </div>
15197
15198 </div>
15199 </div>
15200
15201 <footer class="site-footer">
15202 local code analysis - metrics, history and reports
15203 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
15204 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15205 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15206 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15207 · <a href="/api-docs" rel="noopener">REST API</a>
15208 </footer>
15209
15210 <script nonce="{{ csp_nonce }}">
15211 (function () {
15212 var storageKey = 'oxide-sloc-theme';
15213 var body = document.body;
15214 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15215 var toggle = document.getElementById('theme-toggle');
15216 if (toggle) toggle.addEventListener('click', function () {
15217 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15218 body.classList.toggle('dark-theme', next === 'dark');
15219 try { localStorage.setItem(storageKey, next); } catch(e) {}
15220 });
15221
15222 (function randomizeWatermarks() {
15223 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15224 if (!wms.length) return;
15225 var placed = [];
15226 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; }
15227 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]; }
15228 var half = Math.floor(wms.length / 2);
15229 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; });
15230 })();
15231 (function spawnCodeParticles() {
15232 var container = document.getElementById('code-particles');
15233 if (!container) return;
15234 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'];
15235 var count = 38;
15236 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); }
15237 })();
15238 // Recent scans data injected from server
15239 var recentScans = {{ recent_scans_json|safe }};
15240
15241 function configToParams(cfg) {
15242 var p = new URLSearchParams();
15243 p.set('prefilled', '1');
15244 if (cfg.path) p.set('path', cfg.path);
15245 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15246 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15247 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15248 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15249 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15250 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15251 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15252 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15253 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15254 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15255 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15256 if (cfg.report_title) p.set('report_title', cfg.report_title);
15257 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15258 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15259 return p;
15260 }
15261
15262 // Build recent scan list (capped at 3 visible entries)
15263 var list = document.getElementById('recent-list');
15264 var noNote = document.getElementById('no-recent-note');
15265 var hasAny = false;
15266 var MAX_RECENT = 3;
15267 if (Array.isArray(recentScans)) {
15268 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15269 var shown = 0;
15270 validEntries.forEach(function (entry) {
15271 if (shown >= MAX_RECENT) return;
15272 shown++;
15273 hasAny = true;
15274 var item = document.createElement('div');
15275 item.className = 'recent-item';
15276 item.title = 'Restore all settings and open wizard';
15277 item.innerHTML =
15278 '<div class="recent-item-info">' +
15279 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15280 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
15281 '</div>' +
15282 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15283 item.addEventListener('click', function () {
15284 var params = configToParams(entry.config);
15285 window.location.href = '/scan?' + params.toString();
15286 });
15287 list.appendChild(item);
15288 });
15289 if (validEntries.length > MAX_RECENT) {
15290 var moreEl = document.createElement('div');
15291 moreEl.className = 'recent-more-link';
15292 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
15293 list.appendChild(moreEl);
15294 }
15295 }
15296 if (hasAny && noNote) noNote.style.display = 'none';
15297 // Update count badge
15298 var countEl = document.getElementById('rescan-count-num');
15299 if (countEl) {
15300 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15301 countEl.textContent = total > 0 ? total : '0';
15302 }
15303
15304 // Config file loader
15305 var fileInput = document.getElementById('config-file-input');
15306 var fileName = document.getElementById('config-file-name');
15307 if (fileInput) {
15308 fileInput.addEventListener('change', function () {
15309 var file = fileInput.files && fileInput.files[0];
15310 if (!file) return;
15311 if (fileName) fileName.textContent = '✓ ' + file.name;
15312 var reader = new FileReader();
15313 reader.onload = function (e) {
15314 try {
15315 var cfg = JSON.parse(e.target.result);
15316 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15317 var params = configToParams(cfg);
15318 window.location.href = '/scan?' + params.toString();
15319 } catch (err) {
15320 alert('Could not parse config file: ' + err.message);
15321 }
15322 };
15323 reader.readAsText(file);
15324 });
15325 }
15326
15327 function escHtml(s) {
15328 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
15329 }
15330 })();
15331 </script>
15332 <script nonce="{{ csp_nonce }}">
15333 (function(){
15334 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'}];
15335 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);});}
15336 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15337 function init(){
15338 var btn=document.getElementById('settings-btn');if(!btn)return;
15339 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15340 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>';
15341 document.body.appendChild(m);
15342 var g=document.getElementById('scheme-grid');
15343 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);});
15344 var cl=document.getElementById('settings-close');
15345 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);
15346 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');});
15347 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15348 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15349 }
15350 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15351 }());
15352 </script>
15353 <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>
15354</body>
15355</html>
15356"##,
15357 ext = "html"
15358)]
15359struct ScanSetupTemplate {
15360 version: &'static str,
15361 recent_scans_json: String,
15362 csp_nonce: String,
15363}
15364
15365#[derive(Template)]
15366#[template(
15367 source = r##"
15368<!doctype html>
15369<html lang="en">
15370<head>
15371 <meta charset="utf-8">
15372 <meta name="viewport" content="width=device-width, initial-scale=1">
15373 <title>OxideSLOC | {{ report_title }} | Report</title>
15374 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15375 <style nonce="{{ csp_nonce }}">
15376 :root {
15377 --radius: 18px;
15378 --bg: #f5efe8;
15379 --surface: rgba(255,255,255,0.82);
15380 --surface-2: #fbf7f2;
15381 --surface-3: #efe6dc;
15382 --line: #e6d0bf;
15383 --line-strong: #dcb89f;
15384 --text: #43342d;
15385 --muted: #7b675b;
15386 --muted-2: #a08777;
15387 --nav: #b85d33;
15388 --nav-2: #7a371b;
15389 --accent: #6f9bff;
15390 --accent-2: #4a78ee;
15391 --oxide: #d37a4c;
15392 --oxide-2: #b35428;
15393 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15394 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15395 --success-bg: #e8f5ed;
15396 --success-text: #1a8f47;
15397 --info-bg: #eef3ff;
15398 --info-text: #4467d8;
15399 }
15400
15401 body.dark-theme {
15402 --bg: #1b1511;
15403 --surface: #261c17;
15404 --surface-2: #2d221d;
15405 --surface-3: #372922;
15406 --line: #524238;
15407 --line-strong: #6c5649;
15408 --text: #f5ece6;
15409 --muted: #c7b7aa;
15410 --muted-2: #aa9485;
15411 --nav: #b85d33;
15412 --nav-2: #7a371b;
15413 --accent: #6f9bff;
15414 --accent-2: #4a78ee;
15415 --oxide: #d37a4c;
15416 --oxide-2: #b35428;
15417 --shadow: 0 18px 42px rgba(0,0,0,0.28);
15418 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15419 --success-bg: #163927;
15420 --success-text: #8fe2a8;
15421 --info-bg: #1c2847;
15422 --info-text: #a9c1ff;
15423 }
15424
15425 * { box-sizing: border-box; }
15426 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); }
15427 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15428 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15429 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15430 .top-nav, .page { position: relative; z-index: 2; }
15431 .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); }
15432 .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; }
15433 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15434 .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)); }
15435 .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; }
15436 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15437 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15438 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15439 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15440 .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; }
15441 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15442 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15443 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15444 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15445 @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; } }
15446 .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; }
15447 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15448 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15449 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15450 .theme-toggle .icon-sun { display:none; }
15451 body.dark-theme .theme-toggle .icon-sun { display:block; }
15452 body.dark-theme .theme-toggle .icon-moon { display:none; }
15453 .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;}
15454 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15455 .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);}
15456 .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;}
15457 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15458 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15459 .settings-modal-body{padding:14px 16px 16px;}
15460 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15461 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15462 .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;}
15463 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15464 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15465 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15466 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15467 .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;}
15468 .tz-select:focus{border-color:var(--oxide);}
15469 .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; }
15470 .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;}
15471 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15472 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15473 .hero, .panel { padding: 22px; }
15474 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15475 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15476 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15477 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15478 .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; }
15479 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15480 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15481 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15482 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15483 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15484 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15485 .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; }
15486 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15487 .delta-card-val { font-size:16px; font-weight:800; }
15488 .delta-card-val.pos { color:#1e7e34; }
15489 .delta-card-val.neg { color:var(--neg); }
15490 .delta-card-val.mod { color:#b35428; }
15491 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15492 .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; }
15493 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15494 .delta-card-inline:hover .delta-card-tip { opacity:1; }
15495 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15496 .compare-ts { font-size:13px; color:var(--muted); }
15497 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15498 .compare-arrow { color: var(--muted); }
15499 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15500 .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; }
15501 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15502 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15503 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15504 .run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
15505 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15506 .run-mgmt-card .action-buttons { justify-content:center; }
15507 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15508 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15509 .button, .copy-button {
15510 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;
15511 }
15512 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15513 @keyframes spin { to { transform: rotate(360deg); } }
15514 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15515 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15516 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15517 .path-item strong { display: block; margin-bottom: 6px; }
15518 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15519 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15520 .path-subitem { flex: 1; }
15521 .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); }
15522 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); }
15523 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15524 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15525 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15526 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15527 th { color: var(--muted); font-weight: 700; }
15528 tr:last-child td { border-bottom: none; }
15529 #subm-tbl col:nth-child(1){width:15%;}
15530 #subm-tbl col:nth-child(2){width:31%;}
15531 #subm-tbl col:nth-child(3){width:9%;}
15532 #subm-tbl col:nth-child(4){width:9%;}
15533 #subm-tbl col:nth-child(5){width:9%;}
15534 #subm-tbl col:nth-child(6){width:9%;}
15535 #subm-tbl col:nth-child(7){width:9%;}
15536 #subm-tbl col:nth-child(8){width:9%;}
15537 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15538 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15539 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15540 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15541 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15542 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15543 .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; }
15544 .soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
15545 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15546 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15547 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15548 .muted { color: var(--muted); }
15549 /* Run-ID chip row (mirrors HTML report) */
15550 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15551 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15552 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15553 .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; }
15554 .run-id-chip[data-copy] { cursor:pointer; }
15555 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15556 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15557 .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; }
15558 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15559 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15560 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15561 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15562 a.commit-link-value { color:inherit; text-decoration:none; }
15563 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15564 .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; }
15565 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15566 .run-id-chip:hover .chip-tooltip { opacity:1; }
15567 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15568 .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; }
15569 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15570 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15571 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15572 /* Meta chips row */
15573 .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); }
15574 .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; }
15575 .meta-chip:first-child { padding-left:0; }
15576 .meta-chip:last-child { border-right:none; }
15577 .meta-chip b { color:var(--text); font-weight:700; }
15578 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15579 .site-footer a{color:var(--muted);}
15580 .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; }
15581 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15582 .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; }
15583 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15584 /* Stat chips (matches HTML report) */
15585 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15586 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15587 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15588 .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; }
15589 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15590 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15591 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15592 .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; }
15593 .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; }
15594 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15595 .stat-chip:hover .stat-chip-tip { opacity:1; }
15596 /* Submodule panel */
15597 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15598 /* Metrics tables stack */
15599 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15600 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15601 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15602 .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)); }
15603 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15604 /* Metrics table */
15605 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15606 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15607 .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; }
15608 .metrics-table thead th:not(:first-child) { text-align: right; }
15609 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15610 .metrics-table tbody tr:last-child td { border-bottom: none; }
15611 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15612 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15613 .metrics-table tbody tr:hover td { background: var(--surface-2); }
15614 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15615 .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; }
15616 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15617 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15618 .mt-val-pos { color: var(--pos); font-weight: 700; }
15619 .mt-val-neg { color: var(--neg); font-weight: 700; }
15620 .mt-val-zero { color: var(--muted); }
15621 .mt-val-mod { color: var(--oxide-2); }
15622 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15623 @media (max-width: 1180px) {
15624 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15625 .nav-project-slot, .nav-status { justify-content:flex-start; }
15626 .hero-top { flex-direction: column; }
15627 .run-mgmt-strip { flex-direction: column; }
15628 }
15629 .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;}
15630 @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));}}
15631 .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;}
15632 /* ── Result-page chart controls ─────────────────────────────────────────── */
15633 .r-chart-section{margin-bottom:24px;}
15634 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15635 .section-pair > .panel{flex-shrink:0;}
15636 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15637 .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;}
15638 .r-chart-select:focus{border-color:var(--accent);}
15639 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15640 .r-chart-container svg{display:block;width:100%;height:auto;}
15641 .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;}
15642 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15643 .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;}
15644 .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);}
15645 .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;}
15646 .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;}
15647 .r-chart-modal-close:hover{opacity:.7;}
15648 body.dark-theme .r-chart-modal{background:var(--surface);}
15649 .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15650 .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15651 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15652 .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;}
15653 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15654 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15655 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15656 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15657 #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
15658 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15659 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15660 .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;}
15661 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15662 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15663 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
15664 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15665 .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%;}
15666 .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%;}
15667 body.has-report-banner .top-nav{top:27px;}
15668 body.has-report-banner{padding-bottom:27px;}
15669 </style>
15670</head>
15671<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15672 <div class="background-watermarks" aria-hidden="true">
15673 <img src="/images/logo/logo-text.png" alt="" />
15674 <img src="/images/logo/logo-text.png" alt="" />
15675 <img src="/images/logo/logo-text.png" alt="" />
15676 <img src="/images/logo/logo-text.png" alt="" />
15677 <img src="/images/logo/logo-text.png" alt="" />
15678 <img src="/images/logo/logo-text.png" alt="" />
15679 <img src="/images/logo/logo-text.png" alt="" />
15680 <img src="/images/logo/logo-text.png" alt="" />
15681 <img src="/images/logo/logo-text.png" alt="" />
15682 <img src="/images/logo/logo-text.png" alt="" />
15683 <img src="/images/logo/logo-text.png" alt="" />
15684 <img src="/images/logo/logo-text.png" alt="" />
15685 <img src="/images/logo/logo-text.png" alt="" />
15686 <img src="/images/logo/logo-text.png" alt="" />
15687 </div>
15688 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15689 {% if let Some(banner) = report_header_footer %}
15690 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15691 {% endif %}
15692 <div class="top-nav">
15693 <div class="top-nav-inner">
15694 <a class="brand" href="/">
15695 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15696 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15697 </a>
15698 <div class="nav-project-slot">
15699 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
15700 </div>
15701 <div class="nav-status">
15702 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
15703 <div class="nav-dropdown">
15704 <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>
15705 <div class="nav-dropdown-menu">
15706 <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>
15707 </div>
15708 </div>
15709 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
15710 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15711 <div class="nav-dropdown">
15712 <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>
15713 <div class="nav-dropdown-menu">
15714 <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>
15715 </div>
15716 </div>
15717 <div class="server-status-wrap" id="server-status-wrap">
15718 <div class="nav-pill server-online-pill" id="server-status-pill">
15719 <span class="status-dot" id="status-dot"></span>
15720 <span id="server-status-label">Server</span>
15721 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15722 </div>
15723 <div class="server-status-tip">
15724 OxideSLOC is running — accessible on your network.
15725 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15726 </div>
15727 </div>
15728 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15729 <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>
15730 </button>
15731 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
15732 <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>
15733 <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>
15734 </button>
15735 </div>
15736 </div>
15737 </div>
15738
15739 <div class="page">
15740 <section class="hero">
15741 <div class="hero-top">
15742 <div>
15743 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
15744 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
15745 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
15746 <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
15747 </div>
15748 </div>
15749 <div class="hero-quick-actions">
15750 {% if server_mode %}
15751 <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>
15752 {% else %}
15753 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
15754 {% endif %}
15755 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
15756 {% if !server_mode %}
15757 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
15758 {% endif %}
15759 </div>
15760 </div>
15761
15762 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
15763 <div class="run-id-row">
15764 <span class="run-id-chip" data-copy="{{ run_id }}">
15765 <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>
15766 <span class="run-id-chip-value">{{ run_id }}</span>
15767 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
15768 </span>
15769 {% match git_commit_long %}
15770 {% when Some with (long_sha) %}
15771 {% match git_commit_url %}
15772 {% when Some with (commit_url) %}
15773 <span class="run-id-chip" data-copy="{{ long_sha }}">
15774 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
15775 <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
15776 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
15777 </span>
15778 {% when None %}
15779 <span class="run-id-chip" data-copy="{{ long_sha }}">
15780 <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>
15781 <span class="run-id-chip-value">{{ long_sha }}</span>
15782 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
15783 </span>
15784 {% endmatch %}
15785 {% when None %}
15786 <span class="run-id-chip muted-chip">
15787 <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>
15788 <span class="run-id-chip-value">Not detected</span>
15789 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
15790 </span>
15791 {% endmatch %}
15792 {% match git_branch %}
15793 {% when Some with (branch) %}
15794 <span class="run-id-chip">
15795 <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>
15796 <span class="run-id-chip-value">{{ branch }}</span>
15797 <span class="chip-tooltip">Git branch active at scan time</span>
15798 </span>
15799 {% when None %}
15800 <span class="run-id-chip muted-chip">
15801 <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>
15802 <span class="run-id-chip-value">Not detected</span>
15803 <span class="chip-tooltip">No Git branch was found for this scan</span>
15804 </span>
15805 {% endmatch %}
15806 {% match git_author %}
15807 {% when Some with (author) %}
15808 <span class="run-id-chip" data-author="{{ author }}">
15809 <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>
15810 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
15811 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
15812 </span>
15813 {% when None %}
15814 <span class="run-id-chip muted-chip">
15815 <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>
15816 <span class="run-id-chip-value">Not detected</span>
15817 <span class="chip-tooltip">No commit author was found for this scan</span>
15818 </span>
15819 {% endmatch %}
15820 </div>
15821
15822 <!-- Scan metadata row -->
15823 <div class="meta">
15824 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
15825 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
15826 <span class="meta-chip">Generated <b>{{ generated_display }}</b></span>
15827 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
15828 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
15829 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
15830 </div>
15831
15832 <!-- 12 summary stat chips -->
15833 <div class="summary-strip">
15834 <div class="stat-chip" data-raw="{{ physical_lines }}">
15835 <div class="stat-chip-label">Physical lines</div>
15836 <div class="stat-chip-val">{{ physical_lines }}</div>
15837 <div class="stat-chip-exact"></div>
15838 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
15839 </div>
15840 <div class="stat-chip" data-raw="{{ code_lines }}">
15841 <div class="stat-chip-label">Code</div>
15842 <div class="stat-chip-val">{{ code_lines }}</div>
15843 <div class="stat-chip-exact"></div>
15844 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
15845 </div>
15846 <div class="stat-chip" data-raw="{{ comment_lines }}">
15847 <div class="stat-chip-label">Comments</div>
15848 <div class="stat-chip-val">{{ comment_lines }}</div>
15849 <div class="stat-chip-exact"></div>
15850 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
15851 </div>
15852 <div class="stat-chip" data-raw="{{ blank_lines }}">
15853 <div class="stat-chip-label">Blank</div>
15854 <div class="stat-chip-val">{{ blank_lines }}</div>
15855 <div class="stat-chip-exact"></div>
15856 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
15857 </div>
15858 <div class="stat-chip" data-raw="{{ mixed_lines }}">
15859 <div class="stat-chip-label">Mixed separate</div>
15860 <div class="stat-chip-val">{{ mixed_lines }}</div>
15861 <div class="stat-chip-exact"></div>
15862 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
15863 </div>
15864 <div class="stat-chip" data-raw="{{ functions }}">
15865 <div class="stat-chip-label">Functions</div>
15866 <div class="stat-chip-val">{{ functions }}</div>
15867 <div class="stat-chip-exact"></div>
15868 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
15869 </div>
15870 <div class="stat-chip" data-raw="{{ classes }}">
15871 <div class="stat-chip-label">Classes / Types</div>
15872 <div class="stat-chip-val">{{ classes }}</div>
15873 <div class="stat-chip-exact"></div>
15874 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
15875 </div>
15876 <div class="stat-chip" data-raw="{{ variables }}">
15877 <div class="stat-chip-label">Variables</div>
15878 <div class="stat-chip-val">{{ variables }}</div>
15879 <div class="stat-chip-exact"></div>
15880 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
15881 </div>
15882 <div class="stat-chip" data-raw="{{ imports }}">
15883 <div class="stat-chip-label">Imports</div>
15884 <div class="stat-chip-val">{{ imports }}</div>
15885 <div class="stat-chip-exact"></div>
15886 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
15887 </div>
15888 <div class="stat-chip" data-raw="{{ test_count }}">
15889 <div class="stat-chip-label">Tests</div>
15890 <div class="stat-chip-val">{{ test_count }}</div>
15891 <div class="stat-chip-exact"></div>
15892 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
15893 </div>
15894 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
15895 <div class="stat-chip-label">Code density</div>
15896 <div class="stat-chip-val stat-chip-density-val">—</div>
15897 <div class="stat-chip-exact"></div>
15898 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
15899 </div>
15900 <div class="stat-chip" data-raw="{{ files_analyzed }}">
15901 <div class="stat-chip-label">Files analyzed</div>
15902 <div class="stat-chip-val">{{ files_analyzed }}</div>
15903 <div class="stat-chip-exact"></div>
15904 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
15905 </div>
15906 </div>
15907
15908 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
15909 <div class="compare-banner">
15910 <div class="compare-banner-body">
15911 <div class="compare-banner-meta">
15912 <span class="compare-label">Previous scan</span>
15913 <span class="compare-ts">{{ prev_ts }}</span>
15914 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
15915 {% if let Some(prev_code) = prev_run_code_lines %}
15916 <div class="compare-banner-stats" style="margin-top:4px;">
15917 <span>Code before: <strong>{{ prev_code }}</strong></span>
15918 <span class="compare-arrow">→</span>
15919 <span>Code now: <strong>{{ code_lines }}</strong></span>
15920 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
15921 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
15922 </div>
15923 {% endif %}
15924 </div>
15925 {% if delta_lines_added.is_some() %}
15926 <div class="delta-cards-inline">
15927 <div class="delta-card-inline">
15928 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
15929 <div class="delta-card-lbl">lines added</div>
15930 <div class="delta-card-tip">Code lines added since the previous scan</div>
15931 </div>
15932 <div class="delta-card-inline">
15933 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
15934 <div class="delta-card-lbl">lines removed</div>
15935 <div class="delta-card-tip">Code lines removed since the previous scan</div>
15936 </div>
15937 <div class="delta-card-inline">
15938 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
15939 <div class="delta-card-lbl">unmodified lines</div>
15940 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
15941 </div>
15942 <div class="delta-card-inline">
15943 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
15944 <div class="delta-card-lbl">files modified</div>
15945 <div class="delta-card-tip">Files with at least one line changed</div>
15946 </div>
15947 <div class="delta-card-inline">
15948 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
15949 <div class="delta-card-lbl">files added</div>
15950 <div class="delta-card-tip">New files added since the previous scan</div>
15951 </div>
15952 <div class="delta-card-inline">
15953 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
15954 <div class="delta-card-lbl">files removed</div>
15955 <div class="delta-card-tip">Files deleted since the previous scan</div>
15956 </div>
15957 <div class="delta-card-inline">
15958 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
15959 <div class="delta-card-lbl">files unchanged</div>
15960 <div class="delta-card-tip">Files with no changes since the previous scan</div>
15961 </div>
15962 </div>
15963 {% else %}
15964 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
15965 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
15966 </p>
15967 {% endif %}
15968 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
15969 </div>
15970 </div>
15971 {% endif %}{% endif %}
15972
15973 <div class="action-grid">
15974 <div class="action-card">
15975 <h3>HTML report</h3>
15976 <div class="action-buttons">
15977 {% match html_url %}
15978 {% when Some with (url) %}
15979 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
15980 {% when None %}{% endmatch %}
15981 {% match html_download_url %}
15982 {% when Some with (url) %}
15983 <a class="button secondary" href="{{ url }}">Download HTML</a>
15984 {% when None %}{% endmatch %}
15985 {% match html_path %}
15986 {% when Some with (_path) %}{% when None %}{% endmatch %}
15987 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
15988 </div>
15989 </div>
15990 <div class="action-card">
15991 <h3>PDF report</h3>
15992 <div class="action-buttons">
15993 {% match pdf_url %}
15994 {% when Some with (url) %}
15995 {% if pdf_generating %}
15996 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
15997 <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>
15998 Generating PDF…
15999 </button>
16000 {% else %}
16001 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16002 {% endif %}
16003 {% when None %}
16004 {% match html_url %}
16005 {% when Some with (hurl) %}
16006 <a class="button" href="{{ hurl }}?autoprint=1" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16007 <p class="action-empty-note" style="margin-top:6px;font-size:11px;">
16008 No PDF renderer found on the server. Opens the HTML report in your browser
16009 with the print dialog ready — choose <strong>Save as PDF</strong>.
16010 </p>
16011 {% when None %}
16012 <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;">
16013 PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16014 </p>
16015 {% endmatch %}
16016 {% endmatch %}
16017 {% match pdf_download_url %}
16018 {% when Some with (url) %}
16019 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16020 {% when None %}{% endmatch %}
16021 {% match pdf_url %}
16022 {% when Some with (_) %}
16023 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16024 {% when None %}{% endmatch %}
16025 </div>
16026 </div>
16027 <div class="action-card">
16028 <h3>JSON result</h3>
16029 <div class="action-buttons">
16030 {% match json_url %}
16031 {% when Some with (url) %}
16032 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16033 {% when None %}{% endmatch %}
16034 {% match json_download_url %}
16035 {% when Some with (url) %}
16036 <a class="button secondary" href="{{ url }}">Download JSON</a>
16037 {% when None %}{% endmatch %}
16038 {% match json_path %}
16039 {% when Some with (_path) %}
16040 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16041 {% when None %}
16042 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16043 {% endmatch %}
16044 </div>
16045 </div>
16046 <div class="action-card">
16047 <h3>Scan config</h3>
16048 <div class="action-buttons">
16049 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16050 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16051 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16052 </div>
16053 </div>
16054 {% if confluence_configured %}
16055 <div class="action-card" id="confluenceCard">
16056 <h3>Confluence</h3>
16057 <div class="action-buttons">
16058 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16059 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16060 </div>
16061 <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>
16062 </div>
16063 {% endif %}
16064 </div>
16065 <div class="run-mgmt-strip">
16066 <div class="run-mgmt-card">
16067 <h3>Download bundle</h3>
16068 <div class="action-buttons">
16069 <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16070 </div>
16071 <p class="action-empty-note" style="margin-top:6px;">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
16072 </div>
16073 <div class="run-mgmt-card" id="delete-run-card">
16074 <h3>Delete run</h3>
16075 <div class="action-buttons">
16076 <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16077 </div>
16078 <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16079 </div>
16080 </div>
16081 {% if confluence_configured %}
16082 <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;">
16083 <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);">
16084 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16085 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16086 <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;">
16087 <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>
16088 <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;">
16089 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16090 <div style="display:flex;gap:10px;justify-content:flex-end;">
16091 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16092 <button class="button" id="confSubmitBtn" type="button">Post</button>
16093 </div>
16094 </div>
16095 </div>
16096 {% endif %}
16097 <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;">
16098 <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);">
16099 <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16100 <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>
16101 <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16102 <div style="display:flex;gap:10px;justify-content:flex-end;">
16103 <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16104 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16105 </div>
16106 </div>
16107 </div>
16108 {% if !submodule_rows.is_empty() %}
16109 <div class="submodule-panel">
16110 <div class="toolbar-row">
16111 <div>
16112 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16113 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16114 </div>
16115 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16116 </div>
16117 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16118 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16119 <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>
16120 <thead>
16121 <tr>
16122 <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>
16123 <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>
16124 <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>
16125 <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>
16126 <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>
16127 <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>
16128 <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>
16129 <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>
16130 </tr>
16131 </thead>
16132 <tbody>
16133 {% for row in submodule_rows %}
16134 <tr>
16135 <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>
16136 <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>
16137 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16138 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16139 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16140 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16141 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16142 <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>
16143 </tr>
16144 {% endfor %}
16145 </tbody>
16146 </table>
16147 </div>
16148 </div>
16149 {% endif %}
16150
16151 <div class="metrics-tables-stack">
16152
16153 <div class="metrics-table-wrap">
16154 <div class="metrics-table-title">Files</div>
16155 <table class="metrics-table">
16156 <thead>
16157 <tr>
16158 <th>Metric</th>
16159 <th>This Run</th>
16160 <th>Previous</th>
16161 <th>Change</th>
16162 </tr>
16163 </thead>
16164 <tbody>
16165 <tr>
16166 <td>Files analyzed</td>
16167 <td class="mt-val-large">{{ files_analyzed }}</td>
16168 <td>{{ prev_fa_str }}</td>
16169 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16170 </tr>
16171 <tr>
16172 <td>Files skipped</td>
16173 <td>{{ files_skipped }}</td>
16174 <td>{{ prev_fs_str }}</td>
16175 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16176 </tr>
16177 <tr>
16178 <td>Files modified</td>
16179 <td class="mt-val-na">—</td>
16180 <td class="mt-val-na">—</td>
16181 <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>
16182 </tr>
16183 <tr>
16184 <td>Files unchanged</td>
16185 <td class="mt-val-na">—</td>
16186 <td class="mt-val-na">—</td>
16187 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16188 </tr>
16189 </tbody>
16190 </table>
16191 </div>
16192
16193 <div class="metrics-table-wrap">
16194 <div class="metrics-table-title">Line Counts</div>
16195 <table class="metrics-table">
16196 <thead>
16197 <tr>
16198 <th>Metric</th>
16199 <th>This Run</th>
16200 <th>Previous</th>
16201 <th>Change</th>
16202 </tr>
16203 </thead>
16204 <tbody>
16205 <tr>
16206 <td>Physical lines</td>
16207 <td class="mt-val-large">{{ physical_lines }}</td>
16208 <td>{{ prev_pl_str }}</td>
16209 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16210 </tr>
16211 <tr>
16212 <td>Code lines</td>
16213 <td class="mt-val-large">{{ code_lines }}</td>
16214 <td>{{ prev_cl_str }}</td>
16215 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16216 </tr>
16217 <tr>
16218 <td>Comment lines</td>
16219 <td>{{ comment_lines }}</td>
16220 <td>{{ prev_cml_str }}</td>
16221 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16222 </tr>
16223 <tr>
16224 <td>Blank lines</td>
16225 <td>{{ blank_lines }}</td>
16226 <td>{{ prev_bl_str }}</td>
16227 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16228 </tr>
16229 <tr>
16230 <td>Mixed (separate)</td>
16231 <td>{{ mixed_lines }}</td>
16232 <td class="mt-val-na">—</td>
16233 <td class="mt-val-na">—</td>
16234 </tr>
16235 </tbody>
16236 </table>
16237 </div>
16238
16239 <div class="metrics-tables-lower">
16240 <div class="metrics-table-wrap">
16241 <div class="metrics-table-title">Code Structure</div>
16242 <table class="metrics-table">
16243 <thead>
16244 <tr>
16245 <th>Metric</th>
16246 <th>This Run</th>
16247 </tr>
16248 </thead>
16249 <tbody>
16250 <tr>
16251 <td>Functions</td>
16252 <td>{{ functions }}</td>
16253 </tr>
16254 <tr>
16255 <td>Classes / Types</td>
16256 <td>{{ classes }}</td>
16257 </tr>
16258 <tr>
16259 <td>Variables</td>
16260 <td>{{ variables }}</td>
16261 </tr>
16262 <tr>
16263 <td>Imports</td>
16264 <td>{{ imports }}</td>
16265 </tr>
16266 </tbody>
16267 </table>
16268 </div>
16269
16270 <div class="metrics-table-wrap">
16271 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16272 <table class="metrics-table">
16273 <thead>
16274 <tr>
16275 <th>Metric</th>
16276 <th>Change</th>
16277 </tr>
16278 </thead>
16279 <tbody>
16280 <tr>
16281 <td>Lines added</td>
16282 <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>
16283 </tr>
16284 <tr>
16285 <td>Lines removed</td>
16286 <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>
16287 </tr>
16288 <tr>
16289 <td>Lines modified (net)</td>
16290 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16291 </tr>
16292 <tr>
16293 <td>Lines unmodified</td>
16294 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16295 </tr>
16296 </tbody>
16297 </table>
16298 </div>
16299 </div>
16300
16301 </div>
16302
16303 <div class="path-list">
16304 <div class="path-item">
16305 <div class="path-item-label">Project path</div>
16306 <code>{{ project_path }}</code>
16307 </div>
16308 <div class="path-item">
16309 <div class="path-item-label">Git branch</div>
16310 {% if let Some(branch) = git_branch %}
16311 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16312 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16313 {% else %}
16314 <code style="color:var(--muted)">—</code>
16315 {% endif %}
16316 </div>
16317 <div class="path-item">
16318 <div class="path-item-label">Output folder</div>
16319 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16320 </div>
16321 <div class="path-item">
16322 <div class="path-item-label">Run ID</div>
16323 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16324 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16325 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16326 </div>
16327 </div>
16328 </div>
16329 </section>
16330
16331 <div class="section-pair">
16332 <section class="panel">
16333 <div class="toolbar-row">
16334 <div>
16335 <h2>Language breakdown</h2>
16336 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16337 </div>
16338 </div>
16339 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16340 </section>
16341
16342 <section class="panel r-chart-section">
16343 <div class="toolbar-row" style="margin-bottom:16px;">
16344 <div>
16345 <h2>Visualizations</h2>
16346 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16347 </div>
16348 </div>
16349
16350 <div class="r-viz-grid">
16351 <div class="r-viz-card">
16352 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16353 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16354 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16355 </div>
16356 <div class="r-chart-tab-bar">
16357 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16358 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16359 </div>
16360 <div class="r-chart-container" id="r-composition-chart"></div>
16361 </div>
16362 <div class="r-viz-card">
16363 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16364 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16365 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16366 </div>
16367 <div class="r-chart-container" id="r-scatter-chart"></div>
16368 </div>
16369 {% if has_semantic_data %}
16370 <div class="r-viz-card">
16371 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16372 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16373 <select class="r-chart-select" id="r-semantic-metric">
16374 <option value="functions">Functions</option>
16375 <option value="classes">Classes</option>
16376 <option value="variables">Variables</option>
16377 <option value="imports">Imports</option>
16378 </select>
16379 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16380 </div>
16381 <div class="r-chart-container" id="r-semantic-chart"></div>
16382 </div>
16383 {% endif %}
16384 <div class="r-viz-card">
16385 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16386 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16387 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16388 </div>
16389 <div class="r-chart-container" id="r-density-chart"></div>
16390 </div>
16391 <div class="r-viz-card">
16392 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16393 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16394 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16395 </div>
16396 <div class="r-chart-container" id="r-avglines-chart"></div>
16397 </div>
16398 <div class="r-viz-card">
16399 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16400 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16401 <select class="r-chart-select" id="r-sub-metric">
16402 <option value="code">Code Lines</option>
16403 <option value="comment">Comments</option>
16404 <option value="blank">Blank Lines</option>
16405 <option value="physical">Physical Lines</option>
16406 <option value="files">Files</option>
16407 </select>
16408 <select class="r-chart-select" id="r-sub-sort">
16409 <option value="desc">Value ↓</option>
16410 <option value="asc">Value ↑</option>
16411 <option value="name">Name A→Z</option>
16412 </select>
16413 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢</button>
16414 </div>
16415 <div class="r-chart-container" id="r-submodule-chart"></div>
16416 </div>
16417 </div>
16418
16419 </section>
16420 </div>
16421
16422 </div>
16423
16424 <div id="r-tt" aria-hidden="true"></div>
16425
16426 <script nonce="{{ csp_nonce }}">
16427 (function () {
16428 var body = document.body;
16429 var themeToggle = document.getElementById('theme-toggle');
16430 var storageKey = 'oxide-sloc-theme';
16431
16432 function applyTheme(theme) {
16433 body.classList.toggle('dark-theme', theme === 'dark');
16434 }
16435
16436 function loadSavedTheme() {
16437 try {
16438 var saved = localStorage.getItem(storageKey);
16439 if (saved === 'dark' || saved === 'light') {
16440 applyTheme(saved);
16441 }
16442 } catch (e) {}
16443 }
16444
16445 if (themeToggle) {
16446 themeToggle.addEventListener('click', function () {
16447 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16448 applyTheme(nextTheme);
16449 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16450 });
16451 }
16452
16453 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16454 button.addEventListener('click', function () {
16455 var value = button.getAttribute('data-copy-value') || '';
16456 if (!value) return;
16457 var originalText = button.textContent;
16458 function flashSuccess() {
16459 button.textContent = 'Copied!';
16460 setTimeout(function () { button.textContent = originalText; }, 1800);
16461 }
16462 function flashFail() {
16463 button.textContent = 'Copy failed';
16464 setTimeout(function () { button.textContent = originalText; }, 2000);
16465 }
16466 if (navigator.clipboard && navigator.clipboard.writeText) {
16467 navigator.clipboard.writeText(value).then(flashSuccess, function () {
16468 fallbackCopy(value, flashSuccess, flashFail);
16469 });
16470 } else {
16471 fallbackCopy(value, flashSuccess, flashFail);
16472 }
16473 });
16474 });
16475 function fallbackCopy(text, onSuccess, onFail) {
16476 try {
16477 var ta = document.createElement('textarea');
16478 ta.value = text;
16479 ta.style.position = 'fixed';
16480 ta.style.top = '-9999px';
16481 ta.style.left = '-9999px';
16482 document.body.appendChild(ta);
16483 ta.focus();
16484 ta.select();
16485 var ok = document.execCommand('copy');
16486 document.body.removeChild(ta);
16487 if (ok) { onSuccess(); } else { onFail(); }
16488 } catch (e) { onFail(); }
16489 }
16490
16491 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16492 btn.addEventListener('click', function () {
16493 var folder = btn.getAttribute('data-folder') || '';
16494 if (!folder) return;
16495 fetch('/open-path?path=' + encodeURIComponent(folder))
16496 .then(function (r) { return r.json(); })
16497 .then(function (d) {
16498 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16499 })
16500 .catch(function () {});
16501 });
16502 });
16503
16504 loadSavedTheme();
16505
16506 // ── Compact number formatting for stat chips ──────────────────────────
16507 (function(){
16508 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();}
16509 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16510 var raw=parseInt(chip.getAttribute('data-raw'),10);
16511 if(isNaN(raw))return;
16512 var valEl=chip.querySelector('.stat-chip-val');
16513 if(valEl)valEl.textContent=fmt(raw);
16514 var exactEl=chip.querySelector('.stat-chip-exact');
16515 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16516 });
16517 // Code density chip
16518 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16519 var code=parseInt(chip.getAttribute('data-code'),10);
16520 var phys=parseInt(chip.getAttribute('data-physical'),10);
16521 if(isNaN(code)||isNaN(phys)||phys===0)return;
16522 var pct=(code/phys*100).toFixed(1)+'%';
16523 var valEl=chip.querySelector('.stat-chip-val');
16524 if(valEl)valEl.textContent=pct;
16525 });
16526 // Populate author handle from data-author attribute
16527 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16528 var author=chip.getAttribute('data-author');
16529 var el=chip.querySelector('.author-handle');
16530 if(el)el.textContent='/'+author.replace(/\s+/g,'');
16531 });
16532 // Click-to-copy on run-id-chip elements
16533 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16534 chip.addEventListener('click',function(){
16535 var val=chip.getAttribute('data-copy');
16536 if(!val)return;
16537 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16538 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);}
16539 chip.classList.add('chip-copied-flash');
16540 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16541 });
16542 });
16543 })();
16544
16545 // ── Shared tooltip for all result-page charts ─────────────────────────
16546 var rTT=(function(){
16547 var el=document.getElementById('r-tt');
16548 if(!el)return{s:function(){},h:function(){},m:function(){}};
16549 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16550 function hide(){el.style.display='none';}
16551 function move(e){
16552 var x=e.clientX+16,y=e.clientY-12;
16553 var r=el.getBoundingClientRect();
16554 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16555 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16556 el.style.left=x+'px';el.style.top=y+'px';
16557 }
16558 return{s:show,h:hide,m:move};
16559 })();
16560 window.rTT=rTT;
16561
16562 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16563 (function(){
16564 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16565 document.addEventListener('mouseover',function(e){
16566 var t=e.target;
16567 while(t&&t.getAttribute){
16568 var l=t.getAttribute('data-ttl');
16569 if(l!==null){
16570 var v=t.getAttribute('data-ttv')||'';
16571 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16572 return;
16573 }
16574 t=t.parentNode;
16575 }
16576 });
16577 document.addEventListener('mouseout',function(e){
16578 var t=e.target;
16579 while(t&&t.getAttribute){
16580 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16581 t=t.parentNode;
16582 }
16583 });
16584 document.addEventListener('mousemove',function(e){
16585 var el=document.getElementById('r-tt');
16586 if(el&&el.style.display!=='none')rTT.m(e);
16587 });
16588 })();
16589
16590 // ── Language overview charts ───────────────────────────────────────────
16591 (function(){
16592 var D={{ lang_chart_json|safe }};
16593 if(!D||!D.length)return;
16594 var el=document.getElementById('result-lang-charts');
16595 if(!el)return;
16596 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16597 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16598 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16599 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();}
16600 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16601 function px(n){return Math.round(n);}
16602 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+'"';}
16603 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16604
16605 // Donut chart — height matches the stacked-bar chart so both panels align
16606 var rHb_d=28;
16607 var DH=Math.max(220,D.length*rHb_d+32);
16608 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16609 var legX=204,DW=360;
16610 var legCount=D.length;
16611 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16612 var legYStart=Math.round((DH-legCount*legSpacing)/2);
16613 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">';
16614 if(D.length===1){
16615 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16616 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+'"/>';
16617 } else {
16618 var ang=-Math.PI/2;
16619 D.forEach(function(d,i){
16620 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16621 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16622 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16623 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16624 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16625 var pct=Math.round(d.code/tot*100);
16626 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"/>';
16627 ang+=sw;
16628 });
16629 }
16630 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16631 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16632 D.forEach(function(d,i){
16633 var ly=legYStart+i*legSpacing;
16634 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16635 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16636 });
16637 ds+='</svg>';
16638
16639 // Horizontal stacked-bar chart — fills container width
16640 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16641 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16642 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">';
16643 D.forEach(function(d,i){
16644 var y=6+i*rHb,x=LW;
16645 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16646 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>';
16647 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;
16648 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;
16649 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"/>';
16650 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>';
16651 });
16652 var ly=SH-14;
16653 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>';
16654 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>';
16655 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>';
16656 bs+='</svg>';
16657 el.innerHTML='<div class="r-lang-overview">'+
16658 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16659 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16660 '</div>';
16661 })();
16662
16663 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16664 (function(){
16665 var LANG_D={{ lang_chart_json|safe }};
16666 var SCAT_D={{ scatter_chart_json|safe }};
16667 var SEM_D={{ semantic_chart_json|safe }};
16668 var SUB_D={{ submodule_chart_json|safe }};
16669 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16670 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16671 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();}
16672 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16673 function px(n){return Math.round(n);}
16674 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+'"';}
16675
16676 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16677 function renderCompositionInEl(el,mode,shOvr){
16678 if(!el||!LANG_D||!LANG_D.length)return;
16679 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16680 var LW=110,SH=shOvr||224;
16681 var svgW=Math.max(320,el.offsetWidth||480);
16682 var BW=Math.max(120,svgW-LW-80);
16683 var legendH=24,topPad=4;
16684 var n=LANG_D.length||1;
16685 var rowTotal=Math.floor((SH-legendH-topPad)/n);
16686 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16687 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">';
16688 if(mode==='pct'){
16689 LANG_D.forEach(function(d,i){
16690 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16691 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16692 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16693 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>';
16694 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;
16695 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;
16696 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+'"/>';
16697 var pct=Math.round((d.code||0)/tot2*100);
16698 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16699 });
16700 } else {
16701 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
16702 LANG_D.forEach(function(d,i){
16703 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
16704 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16705 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>';
16706 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;
16707 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;
16708 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+'"/>';
16709 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>';
16710 });
16711 }
16712 var ly=SH-legendH+4;
16713 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>';
16714 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>';
16715 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>';
16716 s+='</svg>';
16717 el.innerHTML=s;
16718 }
16719 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
16720 renderComposition('abs');
16721 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
16722 btn.addEventListener('click',function(){
16723 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
16724 btn.classList.add('active');
16725 renderComposition(btn.getAttribute('data-rcomp'));
16726 });
16727 });
16728
16729 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
16730 function renderScatterInEl(el,hOvr){
16731 if(!el||!SCAT_D||!SCAT_D.length)return;
16732 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
16733 var W=Math.max(320,el.offsetWidth||480);
16734 var cW=W-PL-PR,cH=H-PT-PB;
16735 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16736 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16737 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16738 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">';
16739 [0,0.25,0.5,0.75,1].forEach(function(t){
16740 var y=PT+cH*(1-t);
16741 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16742 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>';
16743 });
16744 [0,0.25,0.5,0.75,1].forEach(function(t){
16745 var x=PL+cW*t;
16746 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16747 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>';
16748 });
16749 SCAT_D.forEach(function(d,i){
16750 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
16751 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
16752 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"/>';
16753 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>';
16754 });
16755 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>';
16756 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>';
16757 s+='</svg>';
16758 el.innerHTML=s;
16759 }
16760 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
16761
16762 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
16763 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
16764 // the old vertical column layout on wide containers.
16765 function renderSemanticInEl(el,key,sh){
16766 if(!el||!SEM_D||!SEM_D.length)return;
16767 var LW=112,SH=sh||224;
16768 var svgW=Math.max(320,el.offsetWidth||480);
16769 var BW=Math.max(120,svgW-LW-80);
16770 var topPad=4,botPad=14;
16771 var n2=SEM_D.length||1;
16772 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
16773 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
16774 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
16775 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">';
16776 SEM_D.forEach(function(d,i){
16777 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
16778 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>';
16779 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"/>';
16780 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>';
16781 });
16782 s+='</svg>';
16783 el.innerHTML=s;
16784 }
16785 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,224);}
16786 var semSel=document.getElementById('r-semantic-metric');
16787 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
16788 var semExpand=document.getElementById('r-semantic-expand');
16789 if(semExpand){
16790 semExpand.addEventListener('click',function(){
16791 var key=semSel?semSel.value:'functions';
16792 var n=SEM_D.length||1;
16793 var modalH=Math.max(624,n*62+96);
16794 var overlay=document.createElement('div');
16795 overlay.className='r-chart-modal-overlay';
16796 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
16797 document.body.appendChild(overlay);
16798 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16799 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16800 var modalEl=document.getElementById('r-sem-modal-chart');
16801 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
16802 });
16803 }
16804
16805 // ── Expand buttons: re-render charts at large size inside modal ──────────
16806 (function(){
16807 function makeExpandModal(title,mH){
16808 var overlay=document.createElement('div');
16809 overlay.className='r-chart-modal-overlay';
16810 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><span class="r-chart-modal-title">'+title+' — Full View</span><div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
16811 document.body.appendChild(overlay);
16812 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16813 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16814 return overlay.querySelector('.r-expand-modal-chart');
16815 }
16816 var compExpandBtn=document.getElementById('r-composition-expand');
16817 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
16818 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
16819 var n=LANG_D.length||1;var mH=Math.max(624,n*62+96);
16820 var wrap=makeExpandModal('Language Composition',mH);
16821 if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
16822 });}
16823 var scatExpandBtn=document.getElementById('r-scatter-expand');
16824 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
16825 var wrap=makeExpandModal('Files vs Code Lines',672);
16826 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
16827 });}
16828 var densExpandBtn=document.getElementById('r-density-expand');
16829 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
16830 var n=LANG_D.length||1;var mH=Math.max(624,n*62+96);
16831 var wrap=makeExpandModal('Comment Density',mH);
16832 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
16833 });}
16834 var avgExpandBtn=document.getElementById('r-avglines-expand');
16835 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
16836 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=Math.max(624,n*62+96);
16837 var wrap=makeExpandModal('Avg Lines per File',mH);
16838 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
16839 });}
16840 var subExpandBtn=document.getElementById('r-submodule-expand');
16841 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
16842 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
16843 var n=(SUB_D.length+1)||1;var mH=Math.max(624,n*43+96);
16844 var wrap=makeExpandModal('Repository Overview',mH);
16845 if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
16846 });}
16847 })();
16848
16849 // ── Comment Density: comments / (code + comments) per language ───────────
16850 function renderDensityInEl(el,shOvr){
16851 if(!el||!LANG_D||!LANG_D.length)return;
16852 var LW=112,SH=shOvr||224;
16853 var svgW=Math.max(320,el.offsetWidth||480);
16854 var BW=Math.max(120,svgW-LW-80);
16855 var topPad=4,botPad=26;
16856 var n=LANG_D.length||1;
16857 var rowTotal=Math.floor((SH-topPad-botPad)/n);
16858 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16859 var densities=LANG_D.map(function(d){
16860 var sig=(d.code||0)+(d.comments||0);
16861 return sig>0?(d.comments||0)/sig:0;
16862 });
16863 var maxDen=Math.max.apply(null,densities)||1;
16864 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">';
16865 LANG_D.forEach(function(d,i){
16866 var den=densities[i],bw=den/maxDen*BW;
16867 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16868 var pct=Math.round(den*100);
16869 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>';
16870 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"/>';
16871 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16872 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>';
16873 });
16874 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>';
16875 s+='</svg>';
16876 el.innerHTML=s;
16877 }
16878 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
16879 renderDensity();
16880
16881 // ── Avg Lines per File: code / files per language ─────────────────────
16882 function renderAvgLinesInEl(el,shOvr){
16883 if(!el||!LANG_D||!LANG_D.length)return;
16884 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
16885 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
16886 var LW=112,SH=shOvr||224;
16887 var svgW=Math.max(320,el.offsetWidth||480);
16888 var BW=Math.max(120,svgW-LW-80);
16889 var topPad=4,botPad=26;
16890 var n=data.length||1;
16891 var rowTotal=Math.floor((SH-topPad-botPad)/n);
16892 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16893 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
16894 var maxAvg=Math.max.apply(null,avgs)||1;
16895 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">';
16896 data.forEach(function(d,i){
16897 var avg=avgs[i],bw=avg/maxAvg*BW;
16898 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16899 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>';
16900 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
16901 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16902 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
16903 });
16904 s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.5">avg code lines per file (higher = larger files)</text>';
16905 s+='</svg>';
16906 el.innerHTML=s;
16907 }
16908 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
16909 renderAvgLines();
16910
16911 // ── Repository Overview: overall row + per-submodule rows ────────────
16912 function renderSubmoduleInEl(el,key,sort,shOvr){
16913 if(!el)return;
16914 var overall={
16915 name:'Overall',
16916 code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
16917 comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
16918 blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
16919 physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
16920 files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
16921 isOverall:true
16922 };
16923 var subs=SUB_D.slice();
16924 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
16925 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
16926 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
16927 var data=[overall].concat(subs);
16928 var rowH=32,bH=22,sepH=subs.length>0?14:0;
16929 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
16930 var svgW=Math.max(320,el.offsetWidth||480);
16931 var LW=116,BW=Math.max(200,svgW-LW-54);
16932 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
16933 var OVERALL_COL='#6b7280';
16934 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">';
16935 var yOff=4;
16936 data.forEach(function(d,i){
16937 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
16938 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
16939 var label=d.name||d.path||'?';
16940 s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
16941 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
16942 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16943 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;"'+(d.isOverall?' font-weight="700"':'')+'>'+fmt(v)+'</text>';
16944 yOff+=rowH;
16945 if(d.isOverall&&subs.length>0){
16946 yOff+=sepH;
16947 }
16948 });
16949 s+='</svg>';
16950 el.innerHTML=s;
16951 }
16952 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
16953 var subSel=document.getElementById('r-sub-metric');
16954 var sortSel=document.getElementById('r-sub-sort');
16955 renderSubmodule('code','desc');
16956 if(subSel){
16957 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
16958 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
16959 }
16960
16961 // Re-render all SVG charts when the window is resized so bars fill the card.
16962 var _rResizeTimer;
16963 window.addEventListener('resize',function(){
16964 clearTimeout(_rResizeTimer);
16965 _rResizeTimer=setTimeout(function(){
16966 var rcompBtn=document.querySelector('[data-rcomp].active');
16967 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
16968 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
16969 if(semSel)renderSemantic(semSel.value||'functions');
16970 renderDensity();
16971 renderAvgLines();
16972 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
16973 },120);
16974 });
16975 })();
16976
16977 (function randomizeWatermarks() {
16978 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16979 if (!wms.length) return;
16980 var placed = [];
16981 function tooClose(top, left) {
16982 for (var i = 0; i < placed.length; i++) {
16983 var dt = Math.abs(placed[i][0] - top);
16984 var dl = Math.abs(placed[i][1] - left);
16985 if (dt < 20 && dl < 18) return true;
16986 }
16987 return false;
16988 }
16989 function pick(leftBand) {
16990 for (var attempt = 0; attempt < 50; attempt++) {
16991 var top = Math.random() * 85 + 5;
16992 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16993 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16994 }
16995 var top = Math.random() * 85 + 5;
16996 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16997 placed.push([top, left]);
16998 return [top, left];
16999 }
17000 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17001 var half = Math.floor(wms.length / 2);
17002 wms.forEach(function (img, i) {
17003 var pos = pick(i < half);
17004 var size = Math.floor(Math.random() * 100 + 160);
17005 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17006 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17007 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;
17008 });
17009 })();
17010
17011 (function spawnCodeParticles() {
17012 var container = document.getElementById('code-particles');
17013 if (!container) return;
17014 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'];
17015 for (var i = 0; i < 38; i++) {
17016 (function(idx) {
17017 var el = document.createElement('span');
17018 el.className = 'code-particle';
17019 el.textContent = snippets[idx % snippets.length];
17020 var left = Math.random() * 94 + 2;
17021 var top = Math.random() * 88 + 6;
17022 var dur = (Math.random() * 10 + 9).toFixed(1);
17023 var delay = (Math.random() * 18).toFixed(1);
17024 var rot = (Math.random() * 26 - 13).toFixed(1);
17025 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17026 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';
17027 container.appendChild(el);
17028 })(i);
17029 }
17030 })();
17031
17032 {% if pdf_generating %}
17033 // Poll for PDF readiness and swap the disabled button to a live link once done.
17034 (function() {
17035 var openBtn = document.getElementById('pdf-open-btn');
17036 var dlBtn = document.getElementById('pdf-download-btn');
17037 function checkPdf() {
17038 fetch('/api/runs/{{ run_id }}/pdf-status')
17039 .then(function(r) { return r.json(); })
17040 .then(function(d) {
17041 if (d.ready) {
17042 if (openBtn) {
17043 var a = document.createElement('a');
17044 a.className = 'button';
17045 a.id = 'pdf-open-btn';
17046 a.href = '/runs/pdf/{{ run_id }}';
17047 a.target = '_blank';
17048 a.rel = 'noopener';
17049 a.textContent = 'Open PDF';
17050 openBtn.replaceWith(a);
17051 }
17052 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17053 } else {
17054 setTimeout(checkPdf, 3000);
17055 }
17056 })
17057 .catch(function() { setTimeout(checkPdf, 5000); });
17058 }
17059 setTimeout(checkPdf, 3000);
17060 })();
17061 {% endif %}
17062
17063 })();
17064 </script>
17065 <script nonce="{{ csp_nonce }}">
17066 (function(){
17067 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'}];
17068 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);});}
17069 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17070 function init(){
17071 var btn=document.getElementById('settings-btn');if(!btn)return;
17072 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17073 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>';
17074 document.body.appendChild(m);
17075 var g=document.getElementById('scheme-grid');
17076 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);});
17077 var cl=document.getElementById('settings-close');
17078 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);
17079 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');});
17080 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17081 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17082 }
17083 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17084 }());
17085 </script>
17086 <footer class="site-footer">
17087 local code analysis - metrics, history and reports
17088 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17089 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17090 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17091 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17092 · <a href="/api-docs" rel="noopener">REST API</a>
17093 </footer>
17094 {% if confluence_configured %}
17095 <script nonce="{{ csp_nonce }}">
17096 (function() {
17097 var postBtn = document.getElementById('postConfluenceBtn');
17098 var copyBtn = document.getElementById('copyWikiBtn');
17099 var modal = document.getElementById('confluenceModal');
17100 if (!postBtn || !modal) return;
17101
17102 postBtn.addEventListener('click', function() {
17103 document.getElementById('confStatus').style.display = 'none';
17104 modal.style.display = 'flex';
17105 });
17106 document.getElementById('confCancelBtn').addEventListener('click', function() {
17107 modal.style.display = 'none';
17108 });
17109 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17110
17111 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17112 var btn = this;
17113 btn.disabled = true;
17114 var status = document.getElementById('confStatus');
17115 status.style.display = 'block';
17116 status.style.background = '#dbeafe';
17117 status.style.color = '#1e40af';
17118 status.textContent = 'Posting to Confluence…';
17119 var resp = await fetch('/api/confluence/post', {
17120 method: 'POST',
17121 headers: { 'Content-Type': 'application/json' },
17122 body: JSON.stringify({
17123 run_id: '{{ run_id }}',
17124 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17125 report_url: document.getElementById('confReportUrl').value.trim() || null
17126 })
17127 });
17128 var data = await resp.json();
17129 if (data.ok) {
17130 status.style.background = '#dcfce7'; status.style.color = '#166534';
17131 status.textContent = 'Posted! Page ID: ' + data.page_id;
17132 } else {
17133 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17134 status.textContent = 'Error: ' + (data.error || 'Unknown error');
17135 }
17136 btn.disabled = false;
17137 });
17138
17139 if (copyBtn) {
17140 copyBtn.addEventListener('click', async function() {
17141 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17142 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17143 var text = await resp.text();
17144 try {
17145 await navigator.clipboard.writeText(text);
17146 var orig = copyBtn.textContent;
17147 copyBtn.textContent = 'Copied!';
17148 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17149 } catch(e) {
17150 alert('Clipboard write failed — check browser permissions.');
17151 }
17152 });
17153 }
17154 })();
17155 </script>
17156 {% endif %}
17157 <script nonce="{{ csp_nonce }}">
17158 (function() {
17159 var deleteBtn = document.getElementById('delete-run-btn');
17160 var modal = document.getElementById('delete-run-modal');
17161 var cancelBtn = document.getElementById('delete-run-cancel');
17162 var confirmBtn= document.getElementById('delete-run-confirm');
17163 if (!deleteBtn || !modal) return;
17164 deleteBtn.addEventListener('click', function() {
17165 document.getElementById('delete-run-status').style.display = 'none';
17166 modal.style.display = 'flex';
17167 });
17168 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17169 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17170 confirmBtn.addEventListener('click', async function() {
17171 confirmBtn.disabled = true;
17172 cancelBtn.disabled = true;
17173 var status = document.getElementById('delete-run-status');
17174 status.style.display = 'block';
17175 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17176 status.textContent = 'Deleting…';
17177 try {
17178 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17179 if (resp.status === 204 || resp.ok) {
17180 status.style.background = '#dcfce7'; status.style.color = '#166534';
17181 status.textContent = 'Deleted. Redirecting…';
17182 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17183 } else {
17184 var d = await resp.json().catch(function(){return {};});
17185 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17186 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17187 confirmBtn.disabled = false;
17188 cancelBtn.disabled = false;
17189 }
17190 } catch (e) {
17191 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17192 status.textContent = 'Network error: ' + String(e);
17193 confirmBtn.disabled = false;
17194 cancelBtn.disabled = false;
17195 }
17196 });
17197 })();
17198 </script>
17199 <script nonce="{{ csp_nonce }}">(function(){
17200 var bundleBtn = document.getElementById('download-bundle-btn');
17201 if (bundleBtn) {
17202 bundleBtn.addEventListener('click', function() {
17203 bundleBtn.disabled = true;
17204 var orig = bundleBtn.textContent;
17205 bundleBtn.textContent = 'Preparing…';
17206 fetch('/api/runs/{{ run_id }}/bundle')
17207 .then(function(r) {
17208 if (!r.ok) throw new Error('HTTP ' + r.status);
17209 return r.blob();
17210 })
17211 .then(function(blob) {
17212 var url = URL.createObjectURL(blob);
17213 var a = document.createElement('a');
17214 a.href = url;
17215 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17216 document.body.appendChild(a);
17217 a.click();
17218 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17219 bundleBtn.disabled = false;
17220 bundleBtn.textContent = orig;
17221 })
17222 .catch(function(e) {
17223 bundleBtn.disabled = false;
17224 bundleBtn.textContent = orig;
17225 alert('Bundle download failed: ' + String(e));
17226 });
17227 });
17228 }
17229 })();</script>
17230 <script nonce="{{ csp_nonce }}">(function(){
17231 var dot=document.getElementById('status-dot');
17232 var pingEl=document.getElementById('server-ping-ms');
17233 var tipEl=document.getElementById('server-tip-ping');
17234 var fm=document.getElementById('footer-mode');
17235 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)';}}
17236 function doPing(){
17237 var t0=performance.now();
17238 fetch('/healthz',{cache:'no-store'})
17239 .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);})
17240 .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)';}});
17241 }
17242 doPing();
17243 setInterval(doPing,5000);
17244 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');}
17245 })();</script>
17246 {% if let Some(banner) = report_header_footer %}
17247 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17248 {% endif %}
17249</body>
17250</html>
17251"##,
17252 ext = "html"
17253)]
17254#[allow(clippy::struct_excessive_bools)]
17256struct ResultTemplate {
17257 version: &'static str,
17258 report_title: String,
17259 project_path: String,
17260 output_dir: String,
17261 run_id: String,
17262 files_analyzed: u64,
17263 files_skipped: u64,
17264 physical_lines: u64,
17265 code_lines: u64,
17266 comment_lines: u64,
17267 blank_lines: u64,
17268 mixed_lines: u64,
17269 functions: u64,
17270 classes: u64,
17271 variables: u64,
17272 imports: u64,
17273 html_url: Option<String>,
17274 pdf_url: Option<String>,
17275 json_url: Option<String>,
17276 html_download_url: Option<String>,
17277 pdf_download_url: Option<String>,
17278 json_download_url: Option<String>,
17279 html_path: Option<String>,
17280 json_path: Option<String>,
17281 prev_run_id: Option<String>,
17282 prev_run_timestamp: Option<String>,
17283 prev_run_code_lines: Option<u64>,
17284 prev_fa_str: String,
17286 prev_fs_str: String,
17287 prev_pl_str: String,
17288 prev_cl_str: String,
17289 prev_cml_str: String,
17290 prev_bl_str: String,
17291 delta_fa_str: String,
17293 delta_fa_class: String,
17294 delta_fs_str: String,
17295 delta_fs_class: String,
17296 delta_pl_str: String,
17297 delta_pl_class: String,
17298 delta_cl_str: String,
17299 delta_cl_class: String,
17300 delta_cml_str: String,
17301 delta_cml_class: String,
17302 delta_bl_str: String,
17303 delta_bl_class: String,
17304 delta_lines_added: Option<i64>,
17306 delta_lines_removed: Option<i64>,
17307 delta_lines_net_str: String,
17308 delta_lines_net_class: String,
17309 delta_files_added: Option<usize>,
17310 delta_files_removed: Option<usize>,
17311 delta_files_modified: Option<usize>,
17312 delta_files_unchanged: Option<usize>,
17313 delta_unmodified_lines: Option<u64>,
17314 git_branch: Option<String>,
17316 git_commit: Option<String>,
17317 git_commit_long: Option<String>,
17318 git_author: Option<String>,
17319 git_commit_url: Option<String>,
17320 scan_performed_by: String,
17322 scan_time_display: String,
17323 generated_display: String,
17324 os_display: String,
17325 test_count: u64,
17326 prev_scan_count: usize,
17328 current_scan_number: usize,
17329 submodule_rows: Vec<SubmoduleRow>,
17331 scan_config_url: String,
17332 lang_chart_json: String,
17333 #[allow(dead_code)]
17335 scatter_chart_json: String,
17336 #[allow(dead_code)]
17337 semantic_chart_json: String,
17338 #[allow(dead_code)]
17339 submodule_chart_json: String,
17340 #[allow(dead_code)]
17341 has_submodule_data: bool,
17342 #[allow(dead_code)]
17343 has_semantic_data: bool,
17344 pdf_generating: bool,
17345 csp_nonce: String,
17346 confluence_configured: bool,
17348 server_mode: bool,
17349 report_header_footer: Option<String>,
17351 run_id_short: String,
17352}
17353
17354#[derive(Template)]
17355#[template(
17356 source = r##"
17357<!doctype html>
17358<html lang="en">
17359<head>
17360 <meta charset="utf-8">
17361 <meta name="viewport" content="width=device-width, initial-scale=1">
17362 <title>OxideSLOC | Analyzing…</title>
17363 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17364 <style nonce="{{ csp_nonce }}">
17365 :root {
17366 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17367 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17368 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17369 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17370 }
17371 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17372 *{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;}
17373 .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);}
17374 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17375 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17376 .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));}
17377 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17378 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17379 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17380 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17381 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17382 @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; } }
17383 .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;}
17384 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17385 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17386 .page-body{padding:32px 24px 36px;}
17387 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17388 .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;}
17389 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17390 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17391 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17392 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17393 .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;}
17394 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17395 .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;}
17396 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17397 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17398 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17399 .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;}
17400 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17401 .hidden{display:none!important;}
17402 .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;}
17403 .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;}
17404 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17405 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17406 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17407 .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);}
17408 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17409 .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;}
17410 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17411 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17412 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17413 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17414 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17415 .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;}
17416 @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));}}
17417 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17418 .site-footer a{color:var(--muted);}
17419 .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;}
17420 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17421 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17422 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17423 </style>
17424</head>
17425<body>
17426 <div class="background-watermarks" aria-hidden="true">
17427 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17428 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17429 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17430 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17431 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17432 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17433 </div>
17434 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17435 <nav class="top-nav">
17436 <div class="top-nav-inner">
17437 <a href="/" class="brand">
17438 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17439 <div class="brand-copy">
17440 <h1 class="brand-title">OxideSLOC</h1>
17441 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17442 </div>
17443 </a>
17444 <div class="nav-right">
17445 <a class="nav-pill" href="/">Home</a>
17446 <div class="nav-dropdown">
17447 <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>
17448 <div class="nav-dropdown-menu">
17449 <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>
17450 </div>
17451 </div>
17452 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17453 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17454 <div class="nav-dropdown">
17455 <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>
17456 <div class="nav-dropdown-menu">
17457 <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>
17458 </div>
17459 </div>
17460 <div class="server-status-wrap" id="server-status-wrap">
17461 <div class="nav-pill server-online-pill" id="server-status-pill">
17462 <span class="status-dot" id="status-dot"></span>
17463 <span id="server-status-label">Server</span>
17464 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17465 </div>
17466 <div class="server-status-tip">
17467 OxideSLOC is running — accessible on your network.
17468 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17469 </div>
17470 </div>
17471 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17472 <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>
17473 </button>
17474 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17475 <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>
17476 <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>
17477 </button>
17478 </div>
17479 </div>
17480 </nav>
17481 <div class="page-body">
17482 <div class="wait-panel">
17483 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17484 <h2 class="wait-title">Analyzing your project…</h2>
17485 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
17486 <div class="path-block">{{ project_path }}</div>
17487 <div class="metrics-row">
17488 <div class="metric-card">
17489 <div class="metric-label">Elapsed</div>
17490 <div class="metric-value" id="elapsed">0s</div>
17491 </div>
17492 <div class="metric-card">
17493 <div class="metric-label">Phase</div>
17494 <div class="metric-value" id="phase">Starting</div>
17495 </div>
17496 </div>
17497 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17498 <div class="warn-slow hidden" id="warn-slow">
17499 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.
17500 </div>
17501 <div class="err-panel hidden" id="err-panel">
17502 <strong>Analysis failed</strong>
17503 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17504 </div>
17505 <div class="actions hidden" id="actions">
17506 <a href="/scan" class="btn-primary">Try Again</a>
17507 <a href="/view-reports" class="btn-outline">View Reports</a>
17508 </div>
17509 </div>
17510 </div>
17511 <script nonce="{{ csp_nonce }}">
17512 (function() {
17513 var WAIT_ID = {{ wait_id_json|safe }};
17514 var startTime = Date.now();
17515 var pollInterval = 1500;
17516 var retries = 0;
17517 var maxRetries = 5;
17518 var warnShown = false;
17519
17520 function elapsed() {
17521 return Math.floor((Date.now() - startTime) / 1000);
17522 }
17523
17524 function updateElapsed() {
17525 var s = elapsed();
17526 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17527 }
17528
17529 function setPhase(txt) {
17530 document.getElementById('phase').textContent = txt;
17531 }
17532
17533 var elapsedTimer = setInterval(updateElapsed, 1000);
17534
17535 function poll() {
17536 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17537 .then(function(r) {
17538 if (!r.ok) throw new Error('HTTP ' + r.status);
17539 return r.json();
17540 })
17541 .then(function(data) {
17542 retries = 0;
17543 if (data.state === 'complete') {
17544 clearInterval(elapsedTimer);
17545 setPhase('Done');
17546 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17547 } else if (data.state === 'failed') {
17548 clearInterval(elapsedTimer);
17549 setPhase('Failed');
17550 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17551 document.getElementById('err-panel').classList.remove('hidden');
17552 document.getElementById('actions').classList.remove('hidden');
17553 } else {
17554 // still running
17555 var s = elapsed();
17556 if (s > 90 && !warnShown) {
17557 warnShown = true;
17558 document.getElementById('warn-slow').classList.remove('hidden');
17559 }
17560 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
17561 setTimeout(poll, pollInterval);
17562 }
17563 })
17564 .catch(function(err) {
17565 retries++;
17566 if (retries >= maxRetries) {
17567 clearInterval(elapsedTimer);
17568 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17569 document.getElementById('err-panel').classList.remove('hidden');
17570 document.getElementById('actions').classList.remove('hidden');
17571 } else {
17572 // exponential back-off capped at 8s
17573 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17574 }
17575 });
17576 }
17577
17578 setTimeout(poll, pollInterval);
17579 })();
17580 </script>
17581 <footer class="site-footer">
17582 local code analysis - metrics, history and reports
17583 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17584 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17585 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17586 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17587 · <a href="/api-docs" rel="noopener">REST API</a>
17588 </footer>
17589 <script nonce="{{ csp_nonce }}">
17590 (function(){
17591 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17592 if(s==="dark")b.classList.add("dark-theme");
17593 var tt=document.getElementById("theme-toggle");
17594 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17595 })();
17596 (function spawnCodeParticles(){
17597 var c=document.getElementById('code-particles');if(!c)return;
17598 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'];
17599 for(var i=0;i<32;i++){(function(idx){
17600 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17601 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17602 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17603 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17604 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17605 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17606 c.appendChild(el);
17607 })(i);}
17608 })();
17609 (function randomizeWatermarks(){
17610 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17611 var placed=[];
17612 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;}
17613 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];}
17614 var half=Math.floor(wms.length/2);
17615 wms.forEach(function(img,i){
17616 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17617 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17618 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17619 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17620 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17621 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17622 });
17623 })();
17624 </script>
17625 <script nonce="{{ csp_nonce }}">
17626 (function(){
17627 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'}];
17628 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);});}
17629 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17630 function init(){
17631 var btn=document.getElementById('settings-btn');if(!btn)return;
17632 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17633 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>';
17634 document.body.appendChild(m);
17635 var g=document.getElementById('scheme-grid');
17636 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);});
17637 var cl=document.getElementById('settings-close');
17638 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);
17639 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');});
17640 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17641 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17642 }
17643 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17644 }());
17645 </script>
17646 <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>
17647</body>
17648</html>
17649"##,
17650 ext = "html"
17651)]
17652struct ScanWaitTemplate {
17653 version: &'static str,
17654 wait_id_json: String,
17655 project_path: String,
17656 csp_nonce: String,
17657}
17658
17659#[derive(Template)]
17660#[template(
17661 source = r##"
17662<!doctype html>
17663<html lang="en">
17664<head>
17665 <meta charset="utf-8">
17666 <meta name="viewport" content="width=device-width, initial-scale=1">
17667 <title>OxideSLOC | Error</title>
17668 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17669 <style nonce="{{ csp_nonce }}">
17670 :root {
17671 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17672 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17673 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17674 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17675 }
17676 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17677 *{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;}
17678 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17679 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17680 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17681 .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);}
17682 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17683 .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));}
17684 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17685 .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;}
17686 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17687 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17688 @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; } }
17689 .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;}
17690 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17691 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17692 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17693 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17694 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17695 .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;}
17696 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17697 .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);}
17698 .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;}
17699 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17700 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17701 .settings-modal-body{padding:14px 16px 16px;}
17702 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17703 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17704 .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;}
17705 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17706 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17707 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17708 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17709 .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;}
17710 .tz-select:focus{border-color:var(--oxide);}
17711 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17712 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17713 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17714 .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;}
17715 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17716 .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);}
17717 .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;}
17718 .btn-secondary:hover{background:var(--line);}
17719 .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;}
17720 .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;}
17721 .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;}
17722 @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));}}
17723 .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;}
17724 </style>
17725</head>
17726<body>
17727 <div class="background-watermarks" aria-hidden="true">
17728 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17729 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17730 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17731 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17732 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17733 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17734 </div>
17735 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17736 <div class="top-nav">
17737 <div class="top-nav-inner">
17738 <a class="brand" href="/">
17739 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17740 <div class="brand-copy">
17741 <div class="brand-title">OxideSLOC</div>
17742 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17743 </div>
17744 </a>
17745 <div class="nav-right">
17746 <a class="nav-pill" href="/">Home</a>
17747 <div class="nav-dropdown">
17748 <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>
17749 <div class="nav-dropdown-menu">
17750 <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>
17751 </div>
17752 </div>
17753 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17754 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17755 <div class="nav-dropdown">
17756 <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>
17757 <div class="nav-dropdown-menu">
17758 <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>
17759 </div>
17760 </div>
17761 <div class="server-status-wrap" id="server-status-wrap">
17762 <div class="nav-pill server-online-pill" id="server-status-pill">
17763 <span class="status-dot" id="status-dot"></span>
17764 <span id="server-status-label">Server</span>
17765 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17766 </div>
17767 <div class="server-status-tip">
17768 OxideSLOC is running — accessible on your network.
17769 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17770 </div>
17771 </div>
17772 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17773 <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>
17774 </button>
17775 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17776 <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>
17777 <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>
17778 </button>
17779 </div>
17780 </div>
17781 </div>
17782
17783 <div class="page">
17784 <div class="panel">
17785 <h1>Error</h1>
17786 <div class="error-box">{{ message }}</div>
17787 <div class="actions">
17788 <a class="btn-primary" href="/scan">Back to setup</a>
17789 {% if let Some(report_url) = last_report_url %}
17790 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
17791 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
17792 {% else %}
17793 <a class="btn-secondary" href="/view-reports">View Reports</a>
17794 {% endif %}
17795 </div>
17796 </div>
17797 </div>
17798 <script nonce="{{ csp_nonce }}">
17799 (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");});})();
17800 (function spawnCodeParticles() {
17801 var container = document.getElementById('code-particles');
17802 if (!container) return;
17803 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'];
17804 for (var i = 0; i < 38; i++) {
17805 (function(idx) {
17806 var el = document.createElement('span');
17807 el.className = 'code-particle';
17808 el.textContent = snippets[idx % snippets.length];
17809 var left = Math.random() * 94 + 2;
17810 var top = Math.random() * 88 + 6;
17811 var dur = (Math.random() * 10 + 9).toFixed(1);
17812 var delay = (Math.random() * 18).toFixed(1);
17813 var rot = (Math.random() * 26 - 13).toFixed(1);
17814 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17815 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';
17816 container.appendChild(el);
17817 })(i);
17818 }
17819 })();
17820 (function randomizeWatermarks() {
17821 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17822 var placed = [];
17823 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; }
17824 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]; }
17825 var half = Math.floor(wms.length/2);
17826 wms.forEach(function(img, i) {
17827 var pos = pick(i < half);
17828 var w = Math.floor(Math.random()*60+80);
17829 var rot = (Math.random()*40-20).toFixed(1);
17830 var op = (Math.random()*0.08+0.05).toFixed(2);
17831 var animDur = (Math.random()*6+5).toFixed(1);
17832 var animDelay = (Math.random()*10).toFixed(1);
17833 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';
17834 });
17835 })();
17836 </script>
17837 <script nonce="{{ csp_nonce }}">
17838 (function(){
17839 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'}];
17840 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);});}
17841 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17842 function init(){
17843 var btn=document.getElementById('settings-btn');if(!btn)return;
17844 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17845 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>';
17846 document.body.appendChild(m);
17847 var g=document.getElementById('scheme-grid');
17848 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);});
17849 var cl=document.getElementById('settings-close');
17850 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);
17851 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');});
17852 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17853 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17854 }
17855 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17856 }());
17857 </script>
17858 <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>
17859</body>
17860</html>
17861"##,
17862 ext = "html"
17863)]
17864struct ErrorTemplate {
17865 message: String,
17866 last_report_url: Option<String>,
17868 last_report_label: Option<String>,
17870 csp_nonce: String,
17871 version: &'static str,
17872}
17873
17874#[derive(Template)]
17877#[template(
17878 source = r##"
17879<!doctype html>
17880<html lang="en">
17881<head>
17882 <meta charset="utf-8">
17883 <meta name="viewport" content="width=device-width, initial-scale=1">
17884 <title>OxideSLOC | Locate Scan Files</title>
17885 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17886 <style nonce="{{ csp_nonce }}">
17887 :root {
17888 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17889 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17890 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17891 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17892 }
17893 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17894 *{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;}
17895 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17896 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17897 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17898 .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);}
17899 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17900 .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));}
17901 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17902 .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;}
17903 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17904 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
17905 @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;}}
17906 .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;}
17907 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17908 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17909 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17910 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17911 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17912 .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;}
17913 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17914 .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);}
17915 .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;}
17916 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17917 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17918 .settings-modal-body{padding:14px 16px 16px;}
17919 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17920 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17921 .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;}
17922 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17923 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17924 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17925 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17926 .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;}
17927 .tz-select:focus{border-color:var(--oxide);}
17928 .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17929 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17930 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17931 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
17932 .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;}
17933 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17934 .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;}
17935 .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;}
17936 .btn-secondary:hover{background:var(--line);}
17937 .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;}
17938 .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;}
17939 .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;}
17940 @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));}}
17941 .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;}
17942 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
17943 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
17944 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
17945 .relocate-row{display:flex;gap:8px;align-items:stretch;}
17946 .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;}
17947 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
17948 body.dark-theme .relocate-input{background:var(--surface-2);}
17949 </style>
17950</head>
17951<body>
17952 <div class="background-watermarks" aria-hidden="true">
17953 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17954 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17955 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17956 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17957 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17958 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17959 </div>
17960 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17961 <div class="top-nav">
17962 <div class="top-nav-inner">
17963 <a class="brand" href="/">
17964 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17965 <div class="brand-copy">
17966 <div class="brand-title">OxideSLOC</div>
17967 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17968 </div>
17969 </a>
17970 <div class="nav-right">
17971 <a class="nav-pill" href="/">Home</a>
17972 <div class="nav-dropdown">
17973 <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>
17974 <div class="nav-dropdown-menu">
17975 <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>
17976 </div>
17977 </div>
17978 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
17979 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17980 <div class="nav-dropdown">
17981 <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>
17982 <div class="nav-dropdown-menu">
17983 <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>
17984 </div>
17985 </div>
17986 <div class="server-status-wrap" id="server-status-wrap">
17987 <div class="nav-pill server-online-pill" id="server-status-pill">
17988 <span class="status-dot" id="status-dot"></span>
17989 <span id="server-status-label">Server</span>
17990 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17991 </div>
17992 <div class="server-status-tip">
17993 OxideSLOC is running — accessible on your network.
17994 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17995 </div>
17996 </div>
17997 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17998 <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>
17999 </button>
18000 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18001 <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>
18002 <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>
18003 </button>
18004 </div>
18005 </div>
18006 </div>
18007
18008 <div class="page">
18009 <div class="panel">
18010 <h1>Scan Files Moved</h1>
18011 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18012 <div class="error-box">{{ message }}</div>
18013 <div class="relocate-section">
18014 <h2>Locate Scan Output</h2>
18015 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18016 <form method="post" action="/relocate-scan">
18017 <input type="hidden" name="run_id" value="{{ run_id }}">
18018 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18019 <div class="relocate-row">
18020 <input type="text" id="relocate-folder" name="folder_path"
18021 value="{{ folder_hint }}"
18022 placeholder="Path to folder containing scan output..."
18023 class="relocate-input" autocomplete="off" spellcheck="false">
18024 {% if !server_mode %}
18025 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
18026 {% endif %}
18027 </div>
18028 <div style="margin-top:12px;">
18029 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18030 </div>
18031 </form>
18032 </div>
18033 <div class="actions">
18034 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18035 <a class="btn-secondary" href="/view-reports">View Reports</a>
18036 </div>
18037 </div>
18038 </div>
18039 <script nonce="{{ csp_nonce }}">
18040 (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");});})();
18041 (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);}})();
18042 (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';});})();
18043 </script>
18044 <script nonce="{{ csp_nonce }}">
18045 (function(){
18046 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'}];
18047 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);});}
18048 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18049 function init(){
18050 var btn=document.getElementById('settings-btn');if(!btn)return;
18051 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18052 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>';
18053 document.body.appendChild(m);
18054 var g=document.getElementById('scheme-grid');
18055 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);});
18056 var cl=document.getElementById('settings-close');
18057 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);
18058 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');});
18059 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18060 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18061 }
18062 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18063 }());
18064 (function(){
18065 var btn=document.getElementById('browse-relocate-btn');
18066 if(!btn)return;
18067 btn.addEventListener('click',function(){
18068 btn.disabled=true;btn.textContent='...';
18069 var inp=document.getElementById('relocate-folder');
18070 var hint=inp?inp.value:'';
18071 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
18072 .then(function(r){return r.ok?r.json():{cancelled:true};})
18073 .then(function(d){
18074 btn.disabled=false;btn.textContent='Browse…';
18075 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18076 })
18077 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18078 });
18079 }());
18080 </script>
18081 <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>
18082</body>
18083</html>
18084"##,
18085 ext = "html"
18086)]
18087struct RelocateScanTemplate {
18088 message: String,
18089 run_id: String,
18090 folder_hint: String,
18091 redirect_url: String,
18092 server_mode: bool,
18093 csp_nonce: String,
18094 version: &'static str,
18095}
18096
18097#[derive(Template)]
18100#[template(
18101 source = r##"
18102<!doctype html>
18103<html lang="en">
18104<head>
18105 <meta charset="utf-8">
18106 <meta name="viewport" content="width=device-width, initial-scale=1">
18107 <title>OxideSLOC | View Reports</title>
18108 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18109 <style nonce="{{ csp_nonce }}">
18110 :root {
18111 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18112 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18113 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18114 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18115 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18116 }
18117 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; }
18118 *{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;}
18119 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18120 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18121 .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);}
18122 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18123 .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));}
18124 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18125 .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;}
18126 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18127 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18128 @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; } }
18129 .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;}
18130 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18131 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18132 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18133 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18134 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18135 .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;}
18136 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18137 .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);}
18138 .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;}
18139 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18140 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18141 .settings-modal-body{padding:14px 16px 16px;}
18142 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18143 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18144 .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;}
18145 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18146 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18147 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18148 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18149 .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;}
18150 .tz-select:focus{border-color:var(--oxide);}
18151 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18152 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18153 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18154 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18155 .panel-meta{font-size:13px;color:var(--muted);}
18156 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18157 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18158 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18159 .per-page-label{font-size:13px;color:var(--muted);}
18160 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;}
18161 .filter-input{min-width:180px;cursor:text;}
18162 .table-wrap{width:100%;overflow-x:auto;}
18163 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18164 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;}
18165 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18166 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18167 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18168 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18169 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18170 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18171 tr:last-child td{border-bottom:none;}
18172 tr:hover td{background:var(--surface-2);}
18173 .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);}
18174 .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);}
18175 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18176 .metric-num{font-weight:700;color:var(--text);}
18177 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18178 .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;}
18179 .btn:hover{background:var(--line);}
18180 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18181 .btn.primary:hover{opacity:.9;}
18182 .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;}
18183 .btn-back:hover{background:var(--line);}
18184 .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;}
18185 .export-btn:hover{background:var(--line);}
18186 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18187 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18188 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18189 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18190 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18191 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18192 .pagination-info{font-size:13px;color:var(--muted);}
18193 .pagination-btns{display:flex;gap:6px;}
18194 .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;}
18195 .pg-btn:hover:not(:disabled){background:var(--line);}
18196 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18197 .pg-btn:disabled{opacity:.35;cursor:default;}
18198 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18199 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18200 .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;}
18201 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18202 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18203 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18204 .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);}
18205 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18206 .stat-chip:hover .stat-chip-tip{opacity:1;}
18207 .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;}
18208 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18209 .site-footer a{color:var(--muted);}
18210 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18211 .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%;}
18212 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18213 .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;}
18214 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18215 .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;}
18216 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18217 .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;}
18218 .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;}
18219 .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;}
18220 @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));}}
18221 .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;}
18222 .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;}
18223 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18224 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18225 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18226 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18227 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18228 .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;}
18229 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18230 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18231 .watched-chip-rm:hover{color:var(--oxide);}
18232 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18233 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18234 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18235 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18236 .rpt-btn{min-width:58px;justify-content:center;}
18237 .flex-row{display:flex;align-items:center;gap:8px;}
18238 .report-cell{overflow:visible;white-space:normal;}
18239 #history-table col:nth-child(1){width:185px;}
18240 #history-table col:nth-child(2){width:220px;}
18241 #history-table col:nth-child(3){width:100px;}
18242 #history-table col:nth-child(4){width:72px;}
18243 #history-table col:nth-child(5){width:82px;}
18244 #history-table col:nth-child(6){width:82px;}
18245 #history-table col:nth-child(7){width:65px;}
18246 #history-table col:nth-child(8){width:90px;}
18247 #history-table col:nth-child(9){width:85px;}
18248 #history-table col:nth-child(10){width:115px;}
18249 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18250 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18251 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18252 .submod-details summary::-webkit-details-marker{display:none;}
18253.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18254 .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;}
18255 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18256 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18257 </style>
18258</head>
18259<body>
18260 <div class="background-watermarks" aria-hidden="true">
18261 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18262 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18263 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18264 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18265 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18266 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18267 </div>
18268 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18269 <div class="top-nav">
18270 <div class="top-nav-inner">
18271 <a class="brand" href="/">
18272 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18273 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18274 </a>
18275 <div class="nav-right">
18276 <a class="nav-pill" href="/">Home</a>
18277 <div class="nav-dropdown">
18278 <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>
18279 <div class="nav-dropdown-menu">
18280 <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>
18281 </div>
18282 </div>
18283 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18284 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18285 <div class="nav-dropdown">
18286 <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>
18287 <div class="nav-dropdown-menu">
18288 <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>
18289 </div>
18290 </div>
18291 <div class="server-status-wrap" id="server-status-wrap">
18292 <div class="nav-pill server-online-pill" id="server-status-pill">
18293 <span class="status-dot" id="status-dot"></span>
18294 <span id="server-status-label">Server</span>
18295 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18296 </div>
18297 <div class="server-status-tip">
18298 OxideSLOC is running — accessible on your network.
18299 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18300 </div>
18301 </div>
18302 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18303 <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>
18304 </button>
18305 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18306 <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>
18307 <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>
18308 </button>
18309 </div>
18310 </div>
18311 </div>
18312
18313 <div class="page">
18314 {% if let Some(err) = browse_error %}
18315 <div class="toast-error">
18316 <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>
18317 {{ err }}
18318 </div>
18319 {% endif %}
18320 {% if linked_count > 0 %}
18321 <div class="toast-success">
18322 <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>
18323 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18324 </div>
18325 {% endif %}
18326 <div class="watched-bar">
18327 <div class="watched-bar-left">
18328 <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>
18329 <span class="watched-label">Watched Folders</span>
18330 <div class="watched-chips">
18331 {% if server_mode %}
18332 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18333 {% else %}
18334 {% for dir in watched_dirs %}
18335 <span class="watched-chip">
18336 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18337 <form method="POST" action="/watched-dirs/remove" style="display:contents">
18338 <input type="hidden" name="folder_path" value="{{ dir }}">
18339 <input type="hidden" name="redirect_to" value="/view-reports">
18340 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
18341 </form>
18342 </span>
18343 {% endfor %}
18344 {% if watched_dirs.is_empty() %}
18345 <span class="watched-none">No folders watched — click Choose to add one</span>
18346 {% endif %}
18347 {% endif %}
18348 </div>
18349 </div>
18350 {% if !server_mode %}
18351 <div class="watched-bar-right">
18352 <button type="button" class="btn" id="add-watched-btn">
18353 <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>
18354 Choose
18355 </button>
18356 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18357 <input type="hidden" name="redirect_to" value="/view-reports">
18358 <button type="submit" class="btn">↻ Refresh</button>
18359 </form>
18360 </div>
18361 {% endif %}
18362 </div>
18363 {% if total_scans > 0 %}
18364 <div class="summary-strip">
18365 <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>
18366 <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>
18367 <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>
18368 <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>
18369 </div>
18370 {% endif %}
18371
18372 <section class="panel">
18373 <div class="panel-header">
18374 <div>
18375 <h1>View Reports</h1>
18376 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18377 {% 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 %}
18378 </div>
18379 <div class="flex-row">
18380 <button type="button" class="export-btn" id="export-csv-btn">
18381 <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>
18382 Export CSV
18383 </button>
18384 <button type="button" class="export-btn" id="export-xls-btn">
18385 <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>
18386 Export Excel
18387 </button>
18388 </div>
18389 </div>
18390
18391 {% if entries.is_empty() %}
18392 <div class="empty-state">
18393 <strong>No reports with viewable HTML yet</strong>
18394 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.
18395 </div>
18396 {% else %}
18397 <div class="filter-row">
18398 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project\u2026">
18399 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18400 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
18401 </div>
18402 <div class="table-wrap">
18403 <table id="history-table">
18404 <colgroup>
18405 <col><col><col><col><col><col><col><col><col><col>
18406 </colgroup>
18407 <thead>
18408 <tr id="history-thead">
18409 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18410 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18411 <th>Run ID<div class="col-resize-handle"></div></th>
18412 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18413 <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>
18414 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18415 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18416 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18417 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
18418 <th>Report<div class="col-resize-handle"></div></th>
18419 </tr>
18420 </thead>
18421 <tbody id="history-tbody">
18422 {% for entry in entries %}
18423 <tr class="history-row" data-run="{{ entry.run_id }}"
18424 data-timestamp="{{ entry.timestamp }}"
18425 data-project="{{ entry.project_label }}"
18426 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18427 data-skipped="{{ entry.files_skipped }}"
18428 data-comments="{{ entry.comment_lines }}"
18429 data-blank="{{ entry.blank_lines }}"
18430 data-branch="{{ entry.git_branch }}"
18431 data-commit="{{ entry.git_commit }}"
18432 data-html-url="/runs/html/{{ entry.run_id }}">
18433 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18434 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18435 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18436 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18437 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18438 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18439 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18440 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
18441 <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>
18442 <td class="report-cell">
18443 <div class="actions-cell">
18444 {% 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 %}
18445 {% 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 %}
18446 </div>
18447 {% if !entry.submodule_links.is_empty() %}
18448 <details class="submod-details">
18449 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
18450 <div class="submod-link-list">
18451 {% for sub in entry.submodule_links %}
18452 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18453 {% endfor %}
18454 </div>
18455 </details>
18456 {% endif %}
18457 </td>
18458 </tr>
18459 {% endfor %}
18460 </tbody>
18461 </table>
18462 </div>
18463 <div class="pagination">
18464 <span class="pagination-info" id="pagination-info"></span>
18465 <div class="pagination-btns" id="pagination-btns"></div>
18466 <div class="flex-row">
18467 <span class="per-page-label">Show</span>
18468 <select class="per-page" id="per-page-sel">
18469 <option value="10">10 per page</option>
18470 <option value="25" selected>25 per page</option>
18471 <option value="50">50 per page</option>
18472 <option value="100">100 per page</option>
18473 </select>
18474 <span class="per-page-label" id="page-range-label"></span>
18475 </div>
18476 </div>
18477 {% endif %}
18478 </section>
18479 </div>
18480
18481 <footer class="site-footer">
18482 local code analysis - metrics, history and reports
18483 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
18484 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18485 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18486 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18487 · <a href="/api-docs" rel="noopener">REST API</a>
18488 </footer>
18489
18490 <script nonce="{{ csp_nonce }}">
18491 (function () {
18492 // ── Theme ──────────────────────────────────────────────────────────────
18493 var storageKey = 'oxide-sloc-theme';
18494 var body = document.body;
18495 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18496 var toggle = document.getElementById('theme-toggle');
18497 if (toggle) toggle.addEventListener('click', function () {
18498 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18499 body.classList.toggle('dark-theme', next === 'dark');
18500 try { localStorage.setItem(storageKey, next); } catch(e) {}
18501 });
18502
18503 // ── State ─────────────────────────────────────────────────────────────
18504 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18505 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18506 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18507
18508 // Aggregate stats from first (most recent) row
18509 if (allRows.length) {
18510 var first = allRows[0];
18511 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();}
18512 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>':'');}
18513 setChipVal('agg-code', first.dataset.code);
18514 setChipVal('agg-files', first.dataset.files);
18515 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18516 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18517 }
18518
18519 // ── Branch filter population ──────────────────────────────────────────
18520 (function() {
18521 var branches = {};
18522 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18523 var sel = document.getElementById('branch-filter');
18524 if (sel) Object.keys(branches).sort().forEach(function(b) {
18525 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18526 });
18527 })();
18528
18529 // ── Filter ────────────────────────────────────────────────────────────
18530 function getFilteredRows() {
18531 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18532 var branch = ((document.getElementById('branch-filter') || {}).value || '');
18533 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18534 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18535 if (branch && (r.dataset.branch || '') !== branch) return false;
18536 return true;
18537 });
18538 }
18539
18540 // ── Pagination ────────────────────────────────────────────────────────
18541 function renderPage() {
18542 var filtered = getFilteredRows();
18543 var total = filtered.length;
18544 var totalPages = Math.max(1, Math.ceil(total / perPage));
18545 currentPage = Math.min(currentPage, totalPages);
18546 var start = (currentPage - 1) * perPage;
18547 var end = Math.min(start + perPage, total);
18548 var shown = {};
18549 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
18550 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
18551 r.style.display = shown[r.dataset.run] ? '' : 'none';
18552 });
18553 var rl = document.getElementById('page-range-label');
18554 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
18555 var info = document.getElementById('pagination-info');
18556 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
18557 var btns = document.getElementById('pagination-btns');
18558 if (!btns) return;
18559 btns.innerHTML = '';
18560 function makeBtn(lbl, pg, active, disabled) {
18561 var b = document.createElement('button');
18562 b.className = 'pg-btn' + (active ? ' active' : '');
18563 b.textContent = lbl; b.disabled = disabled;
18564 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
18565 return b;
18566 }
18567 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
18568 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
18569 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
18570 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
18571 }
18572
18573 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
18574 window.applyFilters = function() { currentPage = 1; renderPage(); };
18575
18576 // ── Sorting ───────────────────────────────────────────────────────────
18577 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
18578 function doSort(col, type, order) {
18579 var tbody = document.getElementById('history-tbody');
18580 if (!tbody) return;
18581 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18582 rows.sort(function(a, b) {
18583 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
18584 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
18585 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18586 return va < vb ? 1 : va > vb ? -1 : 0;
18587 });
18588 rows.forEach(function(r) { tbody.appendChild(r); });
18589 currentPage = 1; renderPage();
18590 }
18591 sortHeaders.forEach(function(th) {
18592 th.addEventListener('click', function(e) {
18593 if (e.target.classList.contains('col-resize-handle')) return;
18594 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
18595 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
18596 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18597 th.classList.add('sort-' + sortOrder);
18598 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
18599 doSort(col, type, sortOrder);
18600 });
18601 });
18602
18603 // ── Column resize ─────────────────────────────────────────────────────
18604 (function() {
18605 var table = document.getElementById('history-table');
18606 if (!table) return;
18607 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18608 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
18609 ths.forEach(function(th, i) {
18610 var handle = th.querySelector('.col-resize-handle');
18611 if (!handle || !cols[i]) return;
18612 var startX, startW;
18613 handle.addEventListener('mousedown', function(e) {
18614 e.stopPropagation(); e.preventDefault();
18615 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18616 handle.classList.add('dragging');
18617 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18618 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18619 document.addEventListener('mousemove', onMove);
18620 document.addEventListener('mouseup', onUp);
18621 });
18622 });
18623 })();
18624
18625 // ── Reset view ────────────────────────────────────────────────────────
18626 window.resetView = function() {
18627 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
18628 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
18629 sortCol = null; sortOrder = 'asc';
18630 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18631 var tbody = document.getElementById('history-tbody');
18632 if (tbody) {
18633 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18634 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18635 rows.forEach(function(r) { tbody.appendChild(r); });
18636 }
18637 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
18638 var table = document.getElementById('history-table');
18639 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18640 currentPage = 1; renderPage();
18641 };
18642
18643 renderPage();
18644
18645 // ── Export helpers ────────────────────────────────────────────────────
18646 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
18647 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18648 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);}
18649 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;');}
18650 function slocXlsx(fname,sheet,hdrs,rows){
18651 var enc=new TextEncoder();
18652 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;}
18653 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;}
18654 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18655 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18656 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18657 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;}
18658 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];}
18659 var rx='<row r="1">';
18660 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
18661 rx+='</row>';
18662 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>';});
18663 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18664 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>';
18665 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>';
18666 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>';
18667 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>',
18668 '_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>',
18669 '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>',
18670 '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>',
18671 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
18672 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'];
18673 var zparts=[],zcds=[],zoff=0,znf=0;
18674 order.forEach(function(name){
18675 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18676 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]);
18677 var entry=new Uint8Array(lha.length+nb.length+sz);
18678 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18679 zparts.push(entry);
18680 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));
18681 var cde=new Uint8Array(cda.length+nb.length);
18682 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18683 zcds.push(cde);zoff+=entry.length;znf++;
18684 });
18685 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18686 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]);
18687 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18688 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18689 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18690 zout.set(new Uint8Array(ea),zpos);
18691 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
18692 }
18693
18694 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
18695 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;}
18696 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
18697 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
18698
18699 var csvBtn = document.getElementById('export-csv-btn');
18700 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
18701 var xlsBtn = document.getElementById('export-xls-btn');
18702 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
18703
18704 // ── Remaining CSP-safe event bindings ────────────────────────────────
18705 (function wireEvents() {
18706 var el;
18707 el = document.getElementById('reset-view-btn');
18708 if (el) el.addEventListener('click', window.resetView);
18709 el = document.getElementById('project-filter');
18710 if (el) el.addEventListener('input', window.applyFilters);
18711 el = document.getElementById('branch-filter');
18712 if (el) el.addEventListener('change', window.applyFilters);
18713 el = document.getElementById('per-page-sel');
18714 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
18715 el = document.getElementById('add-watched-btn');
18716 if (el) el.addEventListener('click', function() {
18717 fetch('/pick-directory?kind=reports')
18718 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
18719 .then(function(data) {
18720 if (!data.cancelled && data.selected_path) {
18721 var form = document.createElement('form');
18722 form.method = 'POST';
18723 form.action = '/watched-dirs/add';
18724 var ri = document.createElement('input');
18725 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
18726 var fi = document.createElement('input');
18727 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
18728 form.appendChild(ri); form.appendChild(fi);
18729 document.body.appendChild(form);
18730 form.submit();
18731 }
18732 })
18733 .catch(function(e) { alert('Could not open folder picker: ' + e); });
18734 });
18735 })();
18736
18737 (function randomizeWatermarks() {
18738 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18739 if (!wms.length) return;
18740 var placed = [];
18741 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;}
18742 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];}
18743 var half=Math.floor(wms.length/2);
18744 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;});
18745 })();
18746
18747 (function spawnCodeParticles() {
18748 var container = document.getElementById('code-particles');
18749 if (!container) return;
18750 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'];
18751 for (var i = 0; i < 38; i++) {
18752 (function(idx) {
18753 var el = document.createElement('span');
18754 el.className = 'code-particle';
18755 el.textContent = snippets[idx % snippets.length];
18756 var left = Math.random() * 94 + 2;
18757 var top = Math.random() * 88 + 6;
18758 var dur = (Math.random() * 10 + 9).toFixed(1);
18759 var delay = (Math.random() * 18).toFixed(1);
18760 var rot = (Math.random() * 26 - 13).toFixed(1);
18761 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18762 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';
18763 container.appendChild(el);
18764 })(i);
18765 }
18766 })();
18767 })();
18768 </script>
18769 <script nonce="{{ csp_nonce }}">
18770 (function(){
18771 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'}];
18772 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);});}
18773 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18774 function init(){
18775 var btn=document.getElementById('settings-btn');if(!btn)return;
18776 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18777 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>';
18778 document.body.appendChild(m);
18779 var g=document.getElementById('scheme-grid');
18780 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);});
18781 var cl=document.getElementById('settings-close');
18782 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);
18783 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');});
18784 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18785 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18786 }
18787 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18788 }());
18789 </script>
18790 <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>
18791</body>
18792</html>
18793"##,
18794 ext = "html"
18795)]
18796struct HistoryTemplate {
18797 version: &'static str,
18798 entries: Vec<HistoryEntryRow>,
18799 total_scans: usize,
18800 linked_count: usize,
18801 browse_error: Option<String>,
18802 watched_dirs: Vec<String>,
18803 csp_nonce: String,
18804 server_mode: bool,
18805}
18806
18807#[derive(Template)]
18810#[template(
18811 source = r##"
18812<!doctype html>
18813<html lang="en">
18814<head>
18815 <meta charset="utf-8">
18816 <meta name="viewport" content="width=device-width, initial-scale=1">
18817 <title>OxideSLOC | Compare Scans</title>
18818 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18819 <style nonce="{{ csp_nonce }}">
18820 :root {
18821 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18822 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18823 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18824 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18825 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
18826 }
18827 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18828 *{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;}
18829 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18830 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18831 .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);}
18832 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18833 .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));}
18834 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18835 .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;}
18836 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18837 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18838 @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; } }
18839 .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;}
18840 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18841 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18842 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18843 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18844 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18845 .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;}
18846 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18847 .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);}
18848 .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;}
18849 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18850 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18851 .settings-modal-body{padding:14px 16px 16px;}
18852 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18853 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18854 .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;}
18855 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18856 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18857 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18858 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18859 .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;}
18860 .tz-select:focus{border-color:var(--oxide);}
18861 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18862 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18863 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18864 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18865 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
18866 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
18867 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18868 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18869 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18870 .per-page-label{font-size:13px;color:var(--muted);}
18871 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;}
18872 .filter-input{min-width:180px;cursor:text;}
18873 .table-wrap{width:100%;overflow-x:auto;}
18874 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
18875 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;}
18876 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18877 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18878 #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;}
18879 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
18880 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
18881 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
18882 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
18883 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
18884 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
18885 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
18886 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
18887 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
18888 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18889 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18890 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18891 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18892 tr:last-child td{border-bottom:none;}
18893 tr.selected td{background:var(--sel-bg);}
18894 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
18895 tr:hover:not(.selected) td{background:var(--surface-2);}
18896 tr{cursor:pointer;}
18897 .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);}
18898 .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);}
18899 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18900 .metric-num{font-weight:700;color:var(--text);}
18901 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18902 .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;}
18903 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
18904 .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;}
18905 .btn:hover{background:var(--line);}
18906 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
18907 .btn.primary:hover{opacity:.9;}
18908 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
18909 .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;}
18910 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18911 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18912 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18913 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18914 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18915 .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;}
18916 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18917 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18918 .watched-chip-rm:hover{color:var(--oxide);}
18919 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18920 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18921 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18922 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18923 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
18924 .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;}
18925 .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;}
18926 .btn-back:hover{background:var(--line);}
18927 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18928 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18929 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18930 .pagination-info{font-size:13px;color:var(--muted);}
18931 .pagination-btns{display:flex;gap:6px;}
18932 .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;}
18933 .pg-btn:hover:not(:disabled){background:var(--line);}
18934 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18935 .pg-btn:disabled{opacity:.35;cursor:default;}
18936 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
18937 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18938 .site-footer a{color:var(--muted);}
18939 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18940 .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;}
18941 .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;}
18942 .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;}
18943 @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));}}
18944 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18945 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18946 .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;}
18947 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18948 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18949 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18950 .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);}
18951 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18952 .stat-chip:hover .stat-chip-tip{opacity:1;}
18953 .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;}
18954 .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;}
18955 .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%;}
18956 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
18957 .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;}
18958 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
18959 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
18960 .hidden{display:none!important;}
18961 .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%;}
18962 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
18963 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
18964 .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;}
18965 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
18966 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
18967 .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;}
18968 .scope-option:hover{background:var(--line);}
18969 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
18970 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
18971 .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;}
18972 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
18973 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
18974 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
18975 .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;}
18976 </style>
18977</head>
18978<body>
18979 <div class="background-watermarks" aria-hidden="true">
18980 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18981 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18982 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18983 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18984 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18985 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18986 </div>
18987 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18988 <div class="top-nav">
18989 <div class="top-nav-inner">
18990 <a class="brand" href="/">
18991 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18992 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
18993 </a>
18994 <div class="nav-right">
18995 <a class="nav-pill" href="/">Home</a>
18996 <div class="nav-dropdown">
18997 <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>
18998 <div class="nav-dropdown-menu">
18999 <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>
19000 </div>
19001 </div>
19002 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19003 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19004 <div class="nav-dropdown">
19005 <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>
19006 <div class="nav-dropdown-menu">
19007 <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>
19008 </div>
19009 </div>
19010 <div class="server-status-wrap" id="server-status-wrap">
19011 <div class="nav-pill server-online-pill" id="server-status-pill">
19012 <span class="status-dot" id="status-dot"></span>
19013 <span id="server-status-label">Server</span>
19014 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19015 </div>
19016 <div class="server-status-tip">
19017 OxideSLOC is running — accessible on your network.
19018 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19019 </div>
19020 </div>
19021 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19022 <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>
19023 </button>
19024 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19025 <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>
19026 <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>
19027 </button>
19028 </div>
19029 </div>
19030 </div>
19031
19032 <div class="page">
19033 <div class="watched-bar">
19034 <div class="watched-bar-left">
19035 <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>
19036 <span class="watched-label">Watched Folders</span>
19037 <div class="watched-chips">
19038 {% if server_mode %}
19039 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19040 {% else %}
19041 {% for dir in watched_dirs %}
19042 <span class="watched-chip">
19043 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19044 <form method="POST" action="/watched-dirs/remove" style="display:contents">
19045 <input type="hidden" name="folder_path" value="{{ dir }}">
19046 <input type="hidden" name="redirect_to" value="/compare-scans">
19047 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
19048 </form>
19049 </span>
19050 {% endfor %}
19051 {% if watched_dirs.is_empty() %}
19052 <span class="watched-none">No folders watched — click Choose to add one</span>
19053 {% endif %}
19054 {% endif %}
19055 </div>
19056 </div>
19057 {% if !server_mode %}
19058 <div class="watched-bar-right">
19059 <button type="button" class="btn" id="add-watched-btn">
19060 <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>
19061 Choose
19062 </button>
19063 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19064 <input type="hidden" name="redirect_to" value="/compare-scans">
19065 <button type="submit" class="btn">↻ Refresh</button>
19066 </form>
19067 </div>
19068 {% endif %}
19069 </div>
19070 {% if total_scans > 0 %}
19071 <div class="summary-strip">
19072 <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>
19073 <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>
19074 <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>
19075 <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>
19076 </div>
19077 {% endif %}
19078 <section class="panel">
19079 <div class="panel-header">
19080 <div>
19081 <h1>Compare Scans</h1>
19082 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19083 </div>
19084 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19085 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19086 <button class="btn primary" id="compare-btn" disabled>
19087 <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>
19088 Compare <span class="sel-count" id="sel-count">0/2</span>
19089 </button>
19090 </div>
19091 </div>
19092 </div>
19093
19094 {% if entries.is_empty() %}
19095 <div class="empty-state">
19096 <strong>No scans yet</strong>
19097 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.
19098 </div>
19099 {% else %}
19100 <div class="filter-row">
19101 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project\u2026">
19102 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19103 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
19104 </div>
19105 <div class="scope-panel hidden" id="scope-panel">
19106 <div class="scope-panel-label">
19107 <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>
19108 Compare scope — choose what to include
19109 </div>
19110 <div class="scope-options" id="scope-options"></div>
19111 </div>
19112 {% if total_scans > 0 %}
19113 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19114 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19115 <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>
19116 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19117 </div>
19118 </div>
19119 {% endif %}
19120 <div class="table-wrap">
19121 <table id="compare-table">
19122 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19123 <thead>
19124 <tr id="compare-thead">
19125 <th><div class="col-resize-handle"></div></th>
19126 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19127 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19128 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19129 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19130 <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>
19131 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19132 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19133 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19134 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
19135 <th>Submodules<div class="col-resize-handle"></div></th>
19136 </tr>
19137 </thead>
19138 <tbody id="compare-tbody">
19139 {% for entry in entries %}
19140 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19141 data-timestamp="{{ entry.timestamp }}"
19142 data-project="{{ entry.project_label }}"
19143 data-files="{{ entry.files_analyzed }}"
19144 data-code="{{ entry.code_lines }}"
19145 data-comments="{{ entry.comment_lines }}"
19146 data-blank="{{ entry.blank_lines }}"
19147 data-branch="{{ entry.git_branch }}"
19148 data-commit="{{ entry.git_commit }}"
19149 data-submodules="{{ entry.submodule_names_csv }}">
19150 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19151 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19152 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19153 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19154 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19155 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19156 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19157 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19158 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19159 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
19160 <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>
19161 </tr>
19162 {% endfor %}
19163 </tbody>
19164 </table>
19165 </div>
19166 <div class="pagination">
19167 <span class="pagination-info" id="pagination-info"></span>
19168 <div class="pagination-btns" id="pagination-btns"></div>
19169 <div class="flex-row">
19170 <span class="per-page-label">Show</span>
19171 <select class="per-page" id="per-page-sel">
19172 <option value="10">10 per page</option>
19173 <option value="25" selected>25 per page</option>
19174 <option value="50">50 per page</option>
19175 <option value="100">100 per page</option>
19176 </select>
19177 <span class="per-page-label" id="page-range-label"></span>
19178 </div>
19179 </div>
19180 {% endif %}
19181 </section>
19182 </div>
19183
19184 <footer class="site-footer">
19185 local code analysis - metrics, history and reports
19186 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19187 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19188 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19189 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19190 · <a href="/api-docs" rel="noopener">REST API</a>
19191 </footer>
19192
19193 <script nonce="{{ csp_nonce }}">
19194 (function () {
19195 // ── Theme ──────────────────────────────────────────────────────────────
19196 var storageKey = 'oxide-sloc-theme';
19197 var body = document.body;
19198 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19199 var toggle = document.getElementById('theme-toggle');
19200 if (toggle) toggle.addEventListener('click', function () {
19201 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19202 body.classList.toggle('dark-theme', next === 'dark');
19203 try { localStorage.setItem(storageKey, next); } catch(e) {}
19204 });
19205
19206 // ── State ─────────────────────────────────────────────────────────────
19207 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19208 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19209 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19210
19211 // ── Stat chips ────────────────────────────────────────────────────────
19212 (function() {
19213 var projects = {}, latestTs = '', latestRow = null;
19214 allRows.forEach(function(r) {
19215 var p = r.dataset.project || ''; if (p) projects[p] = true;
19216 var ts = r.dataset.timestamp || '';
19217 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19218 });
19219 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();}
19220 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>':'');}
19221 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19222 if (latestRow) {
19223 setChipVal('agg-code', latestRow.dataset.code);
19224 setChipVal('agg-files', latestRow.dataset.files);
19225 }
19226 })();
19227
19228 // ── Branch filter population ──────────────────────────────────────────
19229 (function() {
19230 var branches = {};
19231 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19232 var sel = document.getElementById('branch-filter');
19233 if (sel) Object.keys(branches).sort().forEach(function(b) {
19234 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19235 });
19236 })();
19237
19238 // ── Filter ────────────────────────────────────────────────────────────
19239 function getFilteredRows() {
19240 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19241 var branch = ((document.getElementById('branch-filter') || {}).value || '');
19242 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19243 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19244 if (branch && (r.dataset.branch || '') !== branch) return false;
19245 return true;
19246 });
19247 }
19248
19249 // ── Pagination ────────────────────────────────────────────────────────
19250 function renderPage() {
19251 var filtered = getFilteredRows();
19252 var total = filtered.length;
19253 var totalPages = Math.max(1, Math.ceil(total / perPage));
19254 currentPage = Math.min(currentPage, totalPages);
19255 var start = (currentPage - 1) * perPage;
19256 var end = Math.min(start + perPage, total);
19257 var shown = {};
19258 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19259 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19260 r.style.display = shown[r.dataset.run] ? '' : 'none';
19261 });
19262 var rl = document.getElementById('page-range-label');
19263 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19264 var info = document.getElementById('pagination-info');
19265 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19266 var btns = document.getElementById('pagination-btns');
19267 if (!btns) return;
19268 btns.innerHTML = '';
19269 function makeBtn(lbl, pg, active, disabled) {
19270 var b = document.createElement('button');
19271 b.className = 'pg-btn' + (active ? ' active' : '');
19272 b.textContent = lbl; b.disabled = disabled;
19273 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19274 return b;
19275 }
19276 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19277 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19278 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19279 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19280 }
19281
19282 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19283 window.applyFilters = function() { currentPage = 1; renderPage(); };
19284
19285 // ── Sorting ───────────────────────────────────────────────────────────
19286 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19287 function doSort(col, type, order) {
19288 var tbody = document.getElementById('compare-tbody');
19289 if (!tbody) return;
19290 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19291 rows.sort(function(a, b) {
19292 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19293 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19294 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19295 return va < vb ? 1 : va > vb ? -1 : 0;
19296 });
19297 rows.forEach(function(r) { tbody.appendChild(r); });
19298 currentPage = 1; renderPage();
19299 }
19300 sortHeaders.forEach(function(th) {
19301 th.addEventListener('click', function(e) {
19302 if (e.target.classList.contains('col-resize-handle')) return;
19303 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19304 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19305 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19306 th.classList.add('sort-' + sortOrder);
19307 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19308 doSort(col, type, sortOrder);
19309 });
19310 });
19311
19312 // Apply default sort (timestamp desc) on initial load
19313 (function() {
19314 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19315 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19316 })();
19317
19318 // ── Column resize ─────────────────────────────────────────────────────
19319 (function() {
19320 var table = document.getElementById('compare-table');
19321 if (!table) return;
19322 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19323 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19324 ths.forEach(function(th, i) {
19325 var handle = th.querySelector('.col-resize-handle');
19326 if (!handle || !cols[i]) return;
19327 var startX, startW;
19328 handle.addEventListener('mousedown', function(e) {
19329 e.stopPropagation(); e.preventDefault();
19330 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19331 handle.classList.add('dragging');
19332 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19333 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19334 document.addEventListener('mousemove', onMove);
19335 document.addEventListener('mouseup', onUp);
19336 });
19337 });
19338 })();
19339
19340 // ── Reset view ────────────────────────────────────────────────────────
19341 window.resetView = function() {
19342 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19343 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19344 sortCol = null; sortOrder = 'asc';
19345 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19346 var tbody = document.getElementById('compare-tbody');
19347 if (tbody) {
19348 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19349 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19350 rows.forEach(function(r) { tbody.appendChild(r); });
19351 }
19352 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19353 var table = document.getElementById('compare-table');
19354 currentPage = 1; renderPage();
19355 currentPage = 1; renderPage();
19356 };
19357
19358 renderPage();
19359
19360 // ── Row selection state ───────────────────────────────────────────────
19361 var selected = [];
19362 function updateCompareBtn() {
19363 var btn = document.getElementById('compare-btn');
19364 var cnt = document.getElementById('sel-count');
19365 if (!btn) return;
19366 btn.disabled = selected.length !== 2;
19367 if (cnt) cnt.textContent = selected.length + '/2';
19368 }
19369
19370 function toggleRow(row) {
19371 var vid = row.dataset.vid || row.dataset.run;
19372 var idx = selected.indexOf(vid);
19373 if (idx >= 0) {
19374 selected.splice(idx, 1);
19375 row.classList.remove('selected');
19376 var b = document.getElementById('badge-' + vid);
19377 if (b) b.textContent = '';
19378 } else {
19379 if (selected.length >= 2) return;
19380 selected.push(vid);
19381 row.classList.add('selected');
19382 }
19383 selected.forEach(function(v, i) {
19384 var b = document.getElementById('badge-' + v);
19385 if (b) b.textContent = i + 1;
19386 });
19387 updateCompareBtn();
19388 buildScopePanel();
19389 }
19390
19391 // ── Scope panel ───────────────────────────────────────────────────────
19392 var selectedScope = 'all';
19393
19394 function buildScopePanel() {
19395 var panel = document.getElementById('scope-panel');
19396 var opts = document.getElementById('scope-options');
19397 if (!panel || !opts) return;
19398 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19399
19400 // Collect union of submodules from both selected rows.
19401 var allSubs = {};
19402 selected.forEach(function(vid) {
19403 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19404 if (!row) return;
19405 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19406 });
19407 var subList = Object.keys(allSubs).sort();
19408 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19409
19410 panel.classList.remove('hidden');
19411 opts.innerHTML = '';
19412
19413 function makeOption(value, label, title) {
19414 var div = document.createElement('div');
19415 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19416 div.dataset.scopeValue = value;
19417 if (title) div.title = title;
19418 var radio = document.createElement('span');
19419 radio.className = 'scope-option-radio';
19420 var lbl = document.createElement('span');
19421 lbl.textContent = label;
19422 div.appendChild(radio);
19423 div.appendChild(lbl);
19424 div.addEventListener('click', function() {
19425 selectedScope = value;
19426 opts.querySelectorAll('.scope-option').forEach(function(o) {
19427 o.classList.toggle('selected', o.dataset.scopeValue === value);
19428 });
19429 });
19430 return div;
19431 }
19432
19433 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19434 var sep = document.createElement('span');
19435 sep.className = 'scope-option-sep';
19436 opts.appendChild(sep);
19437 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19438 subList.forEach(function(s) {
19439 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19440 });
19441 }
19442
19443 function doCompare() {
19444 if (selected.length !== 2) return;
19445 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19446 if (selectedScope === 'super') url += '&scope=super';
19447 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19448 window.location.href = url;
19449 }
19450
19451 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19452 var cbtn = document.getElementById('compare-btn');
19453 if (cbtn) cbtn.addEventListener('click', doCompare);
19454 var pfEl = document.getElementById('project-filter');
19455 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19456 var bfEl = document.getElementById('branch-filter');
19457 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19458 var rvBtn = document.getElementById('reset-view-btn');
19459 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19460 var ppSel = document.getElementById('per-page-sel');
19461 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19462
19463 var cmpTbody = document.getElementById('compare-tbody');
19464 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19465 var row = e.target.closest('.compare-row');
19466 if (row) toggleRow(row);
19467 });
19468
19469 (function randomizeWatermarks() {
19470 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19471 if (!wms.length) return;
19472 var placed = [];
19473 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;}
19474 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];}
19475 var half=Math.floor(wms.length/2);
19476 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;});
19477 })();
19478
19479 (function spawnCodeParticles() {
19480 var container = document.getElementById('code-particles');
19481 if (!container) return;
19482 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'];
19483 for (var i = 0; i < 38; i++) {
19484 (function(idx) {
19485 var el = document.createElement('span');
19486 el.className = 'code-particle';
19487 el.textContent = snippets[idx % snippets.length];
19488 var left = Math.random() * 94 + 2;
19489 var top = Math.random() * 88 + 6;
19490 var dur = (Math.random() * 10 + 9).toFixed(1);
19491 var delay = (Math.random() * 18).toFixed(1);
19492 var rot = (Math.random() * 26 - 13).toFixed(1);
19493 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19494 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';
19495 container.appendChild(el);
19496 })(i);
19497 }
19498 })();
19499
19500 // ── Watched folder picker ─────────────────────────────────────────────
19501 (function() {
19502 var btn = document.getElementById('add-watched-btn');
19503 if (!btn) return;
19504 btn.addEventListener('click', function() {
19505 fetch('/pick-directory?kind=reports')
19506 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19507 .then(function(data) {
19508 if (!data.cancelled && data.selected_path) {
19509 var form = document.createElement('form');
19510 form.method = 'POST';
19511 form.action = '/watched-dirs/add';
19512 var ri = document.createElement('input');
19513 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19514 var fi = document.createElement('input');
19515 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19516 form.appendChild(ri); form.appendChild(fi);
19517 document.body.appendChild(form);
19518 form.submit();
19519 }
19520 })
19521 .catch(function(e) { alert('Could not open folder picker: ' + e); });
19522 });
19523 })();
19524
19525 // ── Submodule chip truncation ─────────────────────────────────────────
19526 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19527 var chips = cell.querySelectorAll('.submod-chip');
19528 var MAX = 4;
19529 if (chips.length <= MAX) return;
19530 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19531 var badge = document.createElement('span');
19532 badge.className = 'submod-overflow-badge';
19533 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19534 badge.textContent = '+' + (chips.length - MAX) + ' more';
19535 cell.appendChild(badge);
19536 cell.style.maxHeight = 'none';
19537 });
19538 })();
19539 </script>
19540 <script nonce="{{ csp_nonce }}">
19541 (function(){
19542 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'}];
19543 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);});}
19544 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19545 function init(){
19546 var btn=document.getElementById('settings-btn');if(!btn)return;
19547 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19548 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>';
19549 document.body.appendChild(m);
19550 var g=document.getElementById('scheme-grid');
19551 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);});
19552 var cl=document.getElementById('settings-close');
19553 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);
19554 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');});
19555 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19556 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19557 }
19558 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19559 }());
19560 </script>
19561 <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>
19562</body>
19563</html>
19564"##,
19565 ext = "html"
19566)]
19567struct CompareSelectTemplate {
19568 version: &'static str,
19569 entries: Vec<HistoryEntryRow>,
19570 total_scans: usize,
19571 watched_dirs: Vec<String>,
19572 csp_nonce: String,
19573 server_mode: bool,
19574}
19575
19576#[derive(Template)]
19579#[template(
19580 source = r##"
19581<!doctype html>
19582<html lang="en">
19583<head>
19584 <meta charset="utf-8">
19585 <meta name="viewport" content="width=device-width, initial-scale=1">
19586 <title>OxideSLOC | Scan Delta</title>
19587 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19588 <style nonce="{{ csp_nonce }}">
19589 :root {
19590 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
19591 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
19592 --nav:#283790; --nav-2:#013e6b;
19593 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
19594 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
19595 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
19596 }
19597 body.dark-theme {
19598 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
19599 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
19600 }
19601 *{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;}
19602 .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);}
19603 .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;}
19604 .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));}
19605 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19606 .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;}
19607 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
19608 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19609 @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; } }
19610 .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;}
19611 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19612 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19613 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19614 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19615 .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;}
19616 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19617 .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);}
19618 .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;}
19619 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19620 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19621 .settings-modal-body{padding:14px 16px 16px;}
19622 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19623 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19624 .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;}
19625 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19626 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19627 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19628 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19629 .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;}
19630 .tz-select:focus{border-color:var(--oxide);}
19631 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19632 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19633 .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;}
19634 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
19635 .hero-body{display:block;}
19636 .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;}
19637 .btn-back:hover{background:var(--line);}
19638 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
19639 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
19640 .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;}
19641 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
19642 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;}
19643 .muted{color:var(--muted);font-size:14px;}
19644 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
19645 .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;}
19646 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
19647 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
19648 .vpill-arrow{font-size:20px;color:var(--muted);}
19649 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
19650 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
19651 .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;}
19652 .delta-card.delta-card-wide{padding:22px 24px;}
19653 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
19654 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
19655 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
19656 .delta-card-from{font-size:15px;color:var(--muted);}
19657 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
19658 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
19659 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
19660 .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%;}
19661 .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;}
19662 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
19663 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
19664 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
19665 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
19666 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
19667 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
19668 .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;}
19669 .meta-card-commit:hover{color:var(--oxide);}
19670 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
19671 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
19672 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
19673 .meta-value{color:var(--text);font-size:13px;}
19674 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
19675 .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;}
19676 .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);}
19677 .delta-card:hover .dc-tip{display:block;}
19678 .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;}
19679 .export-btn:hover{background:var(--line);}
19680 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
19681 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
19682 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
19683 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
19684 .delta-card-change.zero{color:var(--muted);background:transparent;}
19685 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
19686 .delta-card-pct.pos{color:var(--pos);}
19687 .delta-card-pct.neg{color:var(--neg);}
19688 .delta-card-pct.zero{color:var(--muted);}
19689 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
19690 .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;}
19691 .insight-card.insight-flag{border-color:var(--oxide);}
19692 .insight-card:hover .dc-tip{display:block;}
19693 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
19694 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
19695 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
19696 .insight-label.flag{color:var(--oxide);}
19697 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
19698 .insight-val.pos{color:var(--pos);}
19699 .insight-val.neg{color:var(--neg);}
19700 .insight-val.high{color:#c0392a;}
19701 .insight-val.med{color:#926000;}
19702 .insight-val.low{color:var(--pos);}
19703 body.dark-theme .insight-val.high{color:#ff6b6b;}
19704 body.dark-theme .insight-val.med{color:#f0c060;}
19705 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
19706 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
19707 .fc-row{display:flex;align-items:center;gap:8px;}
19708 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
19709 .fc-label{color:var(--muted);}
19710 .fc-modified .fc-count{color:#926000;}
19711 .fc-added .fc-count{color:var(--pos);}
19712 .fc-removed .fc-count{color:var(--neg);}
19713 .fc-unchanged .fc-count{color:var(--muted);}
19714 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
19715 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
19716 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
19717 .chip.modified{background:#fff2d8;color:#926000;}
19718 .chip.added{background:#e8f5ed;color:#1a8f47;}
19719 .chip.removed{background:#fdeaea;color:#b33b3b;}
19720 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
19721 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
19722 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
19723 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
19724 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
19725 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
19726 .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;}
19727 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
19728 .tab-btn:hover:not(.active){background:var(--line);}
19729 .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;}
19730 .btn-reset:hover{background:var(--line);}
19731 .table-wrap{width:100%;overflow-x:auto;}
19732 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19733 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;}
19734 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19735 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19736 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19737 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19738 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19739 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19740 tr:last-child td{border-bottom:none;}
19741 tr.row-added td{background:rgba(26,143,71,0.06);}
19742 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
19743 tr.row-modified td{background:rgba(146,96,0,0.05);}
19744 tr.row-unchanged td{opacity:.6;}
19745 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
19746 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
19747 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
19748 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
19749 .status-badge.modified{background:#fff2d8;color:#926000;}
19750 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
19751 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
19752 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
19753 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
19754 .delta-val{font-weight:700;}
19755 .delta-val.pos{color:var(--pos);}
19756 .delta-val.neg{color:var(--neg);}
19757 .delta-val.zero{color:var(--muted);}
19758 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
19759 .from-to strong{color:var(--text);}
19760 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19761 .site-footer a{color:var(--muted);}
19762 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
19763 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
19764 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19765 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19766 .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;}
19767 .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;}
19768 .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;}
19769 @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));}}
19770 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
19771 .path-link:hover{color:var(--oxide-2);}
19772 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
19773 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
19774 a.vpill-id:hover{color:var(--oxide);}
19775 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
19776 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19777 .pagination-info{font-size:13px;color:var(--muted);}
19778 .pagination-btns{display:flex;gap:6px;}
19779 .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;}
19780 .pg-btn:hover:not(:disabled){background:var(--line);}
19781 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19782 .pg-btn:disabled{opacity:.35;cursor:default;}
19783 .per-page-label{font-size:13px;color:var(--muted);}
19784 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;}
19785 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19786 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
19787 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
19788 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
19789 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
19790 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
19791 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
19792 .tab-btn.tab-unchanged{color:var(--muted);}
19793 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
19794 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
19795 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
19796 .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;}
19797 .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;}
19798 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
19799 .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;}
19800 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19801 .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;}
19802 .submod-scope-btn:hover{background:var(--line);}
19803 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19804 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
19805 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
19806 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
19807 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
19808 body.dark-theme .ic-card{background:var(--surface-2);}
19809 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
19810 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
19811 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
19812 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
19813 #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;}
19814 </style>
19815</head>
19816<body>
19817 <div class="background-watermarks" aria-hidden="true">
19818 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19819 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19820 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19821 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19822 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19823 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19824 </div>
19825 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19826 <div class="top-nav">
19827 <div class="top-nav-inner">
19828 <a class="brand" href="/">
19829 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19830 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
19831 </a>
19832 <div class="nav-right">
19833 <a class="nav-pill" href="/">Home</a>
19834 <div class="nav-dropdown">
19835 <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>
19836 <div class="nav-dropdown-menu">
19837 <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>
19838 </div>
19839 </div>
19840 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19841 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19842 <div class="nav-dropdown">
19843 <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>
19844 <div class="nav-dropdown-menu">
19845 <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>
19846 </div>
19847 </div>
19848 <div class="server-status-wrap" id="server-status-wrap">
19849 <div class="nav-pill server-online-pill" id="server-status-pill">
19850 <span class="status-dot" id="status-dot"></span>
19851 <span id="server-status-label">Server</span>
19852 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19853 </div>
19854 <div class="server-status-tip">
19855 OxideSLOC is running — accessible on your network.
19856 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19857 </div>
19858 </div>
19859 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19860 <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>
19861 </button>
19862 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19863 <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>
19864 <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>
19865 </button>
19866 </div>
19867 </div>
19868 </div>
19869
19870 <div class="page">
19871 <section class="hero">
19872 <div class="hero-header">
19873 <div>
19874 <h1 class="delta-title">Scan Delta</h1>
19875 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
19876 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
19877 {% if let Some(sub) = active_submodule %}
19878 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
19879 {% else if super_scope_active %}
19880 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
19881 {% else %}
19882 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
19883 {% endif %}
19884 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
19885 </div>
19886 </div>
19887 <a class="btn-back" href="/compare-scans">
19888 <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>
19889 Compare Scans
19890 </a>
19891 </div>
19892 {% if has_any_submodule_data %}
19893 <div class="submod-scope-bar">
19894 <span class="submod-scope-label">
19895 <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>
19896 Scope:
19897 </span>
19898 <div class="submod-scope-divider"></div>
19899 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
19900 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
19901 title="All files — super-repo and all submodules combined">Full scan</a>
19902 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
19903 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
19904 title="Only files that are not part of any submodule">Super-repo only</a>
19905 {% for sub in submodule_options %}
19906 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
19907 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
19908 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
19909 {% endfor %}
19910 </div>
19911 {% endif %}
19912 <div class="hero-body">
19913 <div class="meta-strip">
19914 <div class="delta-card delta-card-meta">
19915 <div class="meta-card-header">
19916 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
19917 <div class="meta-card-project-col">
19918 <div class="meta-card-project">{{ project_name }}</div>
19919 {% if has_any_submodule_data %}
19920 {% if let Some(sub) = active_submodule %}
19921 <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>
19922 {% else if super_scope_active %}
19923 <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>
19924 {% else %}
19925 <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>
19926 {% endif %}
19927 {% endif %}
19928 </div>
19929 </div>
19930 {% if !baseline_git_commit.is_empty() %}
19931 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
19932 {% else %}
19933 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
19934 {% endif %}
19935 <div class="meta-card-rows">
19936 <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>
19937 <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>
19938 <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>
19939 <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>
19940 {% if let Some(tags) = baseline_git_tags %}
19941 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19942 {% endif %}
19943 </div>
19944 </div>
19945 <div class="delta-card delta-card-meta">
19946 <div class="meta-card-header">
19947 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
19948 <div class="meta-card-project-col">
19949 <div class="meta-card-project">{{ project_name }}</div>
19950 {% if has_any_submodule_data %}
19951 {% if let Some(sub) = active_submodule %}
19952 <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>
19953 {% else if super_scope_active %}
19954 <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>
19955 {% else %}
19956 <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>
19957 {% endif %}
19958 {% endif %}
19959 </div>
19960 </div>
19961 {% if !current_git_commit.is_empty() %}
19962 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
19963 {% else %}
19964 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
19965 {% endif %}
19966 <div class="meta-card-rows">
19967 <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>
19968 <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>
19969 <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>
19970 <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>
19971 {% if let Some(tags) = current_git_tags %}
19972 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19973 {% endif %}
19974 </div>
19975 </div>
19976 </div>
19977 <div class="delta-strip">
19978 <div class="delta-card">
19979 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
19980 <div class="delta-card-label">Code lines</div>
19981 <div class="delta-card-from">Before: {{ baseline_code }}</div>
19982 <div class="delta-card-to">{{ current_code }}</div>
19983 {% 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>
19984 {% 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>
19985 {% else %}<div class="delta-card-pct zero">±0%</div>
19986 {% endif %}
19987 </div>
19988 <div class="delta-card">
19989 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
19990 <div class="delta-card-label">Files analyzed</div>
19991 <div class="delta-card-from">Before: {{ baseline_files }}</div>
19992 <div class="delta-card-to">{{ current_files }}</div>
19993 {% 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>
19994 {% 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>
19995 {% else %}<div class="delta-card-pct zero">±0%</div>
19996 {% endif %}
19997 </div>
19998 <div class="delta-card">
19999 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20000 <div class="delta-card-label">Comment lines</div>
20001 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20002 <div class="delta-card-to">{{ current_comments }}</div>
20003 {% 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>
20004 {% 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>
20005 {% else %}<div class="delta-card-pct zero">±0%</div>
20006 {% endif %}
20007 </div>
20008 {{ coverage_delta_card|safe }}
20009 <div class="delta-card delta-card-wide">
20010 <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>
20011 <div class="delta-card-label">File changes</div>
20012 <div class="file-changes-grid">
20013 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20014 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20015 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20016 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20017 </div>
20018 </div>
20019 </div>
20020 <div class="insights-panel">
20021 <div class="insight-card">
20022 <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>
20023 <div class="insight-label">Lines Added</div>
20024 <div class="insight-val pos">+{{ code_lines_added }}</div>
20025 <div class="insight-sub">New or grown source lines</div>
20026 </div>
20027 <div class="insight-card">
20028 <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>
20029 <div class="insight-label">Lines Removed</div>
20030 <div class="insight-val neg">−{{ code_lines_removed }}</div>
20031 <div class="insight-sub">Deleted or shrunk source lines</div>
20032 </div>
20033 <div class="insight-card">
20034 <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>
20035 <div class="insight-label">Churn Rate</div>
20036 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20037 <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>
20038 </div>
20039 {% if scope_flag %}
20040 <div class="insight-card insight-flag">
20041 <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>
20042 <div class="insight-label flag">Scope Signal</div>
20043 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20044 <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>
20045 </div>
20046 {% endif %}
20047 </div>
20048 </div>
20049 </section>
20050
20051 <section class="panel" id="inline-charts-section">
20052 <h2>Scan Delta Charts</h2>
20053 <div class="ic-grid">
20054 <div class="ic-card">
20055 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
20056 <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>
20057 <div id="ic-c1"></div>
20058 </div>
20059 <div class="ic-card" id="ic-lang-card">
20060 <div class="ic-card-h2">Language Code Delta</div>
20061 <div id="ic-c3"></div>
20062 </div>
20063 <div class="ic-card">
20064 <div class="ic-card-h2">Delta by Metric</div>
20065 <div id="ic-c2"></div>
20066 </div>
20067 <div class="ic-card">
20068 <div class="ic-card-h2">File Change Distribution</div>
20069 <div id="ic-c4"></div>
20070 </div>
20071 </div>
20072 </section>
20073
20074 <section class="panel">
20075 <h2>File-level delta</h2>
20076 <div class="filter-tabs-row">
20077 <div class="filter-tabs">
20078 <button class="tab-btn tab-all active" data-filter="all">All</button>
20079 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20080 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20081 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20082 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20083 </div>
20084 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20085 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
20086 <div class="export-group">
20087 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
20088 <button type="button" class="export-btn" id="delta-csv-btn">
20089 <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>
20090 CSV
20091 </button>
20092 <button type="button" class="export-btn" id="delta-xls-btn">
20093 <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>
20094 Excel
20095 </button>
20096 <button type="button" class="export-btn" id="delta-charts-btn">
20097 <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>
20098 Charts
20099 </button>
20100 </div>
20101 </div>
20102 </div>
20103
20104 <div class="table-wrap">
20105 <table id="delta-table">
20106 <colgroup>
20107 <col>
20108 <col>
20109 <col>
20110 <col>
20111 <col>
20112 <col>
20113 <col>
20114 </colgroup>
20115 <thead>
20116 <tr id="delta-thead">
20117 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20118 <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>
20119 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
20120 <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>
20121 <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>
20122 <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>
20123 <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>
20124 </tr>
20125 </thead>
20126 <tbody id="delta-tbody">
20127 {% for row in file_rows %}
20128 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20129 data-path="{{ row.relative_path }}"
20130 data-language="{{ row.language }}"
20131 data-baseline-code="{{ row.baseline_code }}"
20132 data-current-code="{{ row.current_code }}"
20133 data-code-delta="{{ row.code_delta_str }}"
20134 data-comment-delta="{{ row.comment_delta_str }}"
20135 data-total-delta="{{ row.total_delta_str }}"
20136 data-orig-idx="">
20137 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20138 <td class="hide-sm">{{ row.language }}</td>
20139 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20140 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20141 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20142 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20143 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20144 </tr>
20145 {% endfor %}
20146 </tbody>
20147 </table>
20148 </div>
20149 <div class="pagination">
20150 <span class="pagination-info" id="pg-info"></span>
20151 <div class="pagination-btns" id="pg-btns"></div>
20152 <div class="flex-row">
20153 <span class="per-page-label">Show</span>
20154 <select class="per-page" id="per-page-sel">
20155 <option value="10">10 per page</option>
20156 <option value="25" selected>25 per page</option>
20157 <option value="50">50 per page</option>
20158 <option value="100">100 per page</option>
20159 </select>
20160 <span class="per-page-label" id="pg-range-label"></span>
20161 </div>
20162 </div>
20163 </section>
20164 </div>
20165
20166 <div id="ic-tt"></div>
20167
20168 <footer class="site-footer">
20169 local code analysis - metrics, history and reports
20170 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20171 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20172 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20173 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20174 · <a href="/api-docs" rel="noopener">REST API</a>
20175 </footer>
20176
20177 <script nonce="{{ csp_nonce }}">
20178 (function () {
20179 var storageKey = 'oxide-sloc-theme';
20180 var body = document.body;
20181 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20182 var toggle = document.getElementById('theme-toggle');
20183 if (toggle) toggle.addEventListener('click', function () {
20184 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20185 body.classList.toggle('dark-theme', next === 'dark');
20186 try { localStorage.setItem(storageKey, next); } catch(e) {}
20187 });
20188
20189 (function randomizeWatermarks() {
20190 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20191 if (!wms.length) return;
20192 var placed = [];
20193 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;}
20194 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];}
20195 var half=Math.floor(wms.length/2);
20196 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;});
20197 })();
20198
20199 (function spawnCodeParticles() {
20200 var container = document.getElementById('code-particles');
20201 if (!container) return;
20202 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'];
20203 for (var i = 0; i < 38; i++) {
20204 (function(idx) {
20205 var el = document.createElement('span');
20206 el.className = 'code-particle';
20207 el.textContent = snippets[idx % snippets.length];
20208 var left = Math.random() * 94 + 2;
20209 var top = Math.random() * 88 + 6;
20210 var dur = (Math.random() * 10 + 9).toFixed(1);
20211 var delay = (Math.random() * 18).toFixed(1);
20212 var rot = (Math.random() * 26 - 13).toFixed(1);
20213 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20214 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';
20215 container.appendChild(el);
20216 })(i);
20217 }
20218 })();
20219 })();
20220
20221 var activeStatusFilter = 'all';
20222 var deltaPerPage = 25, deltaCurrPage = 1;
20223
20224 function openFolder(path) {
20225 fetch('/open-path?path=' + encodeURIComponent(path))
20226 .then(function (r) { return r.json(); })
20227 .then(function (d) {
20228 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20229 })
20230 .catch(function () {});
20231 }
20232
20233 function getDeltaFilteredRows() {
20234 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20235 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20236 });
20237 }
20238
20239 function renderDeltaPage() {
20240 var filtered = getDeltaFilteredRows();
20241 var total = filtered.length;
20242 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20243 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20244 var start = (deltaCurrPage - 1) * deltaPerPage;
20245 var end = Math.min(start + deltaPerPage, total);
20246 var shownSet = {};
20247 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20248 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20249 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20250 });
20251 var rl = document.getElementById('pg-range-label');
20252 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20253 var info = document.getElementById('pg-info');
20254 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20255 var btns = document.getElementById('pg-btns');
20256 if (!btns) return;
20257 btns.innerHTML = '';
20258 if (totalPages <= 1) return;
20259 function makeBtn(lbl, pg, active, disabled) {
20260 var b = document.createElement('button');
20261 b.className = 'pg-btn' + (active ? ' active' : '');
20262 b.textContent = lbl; b.disabled = disabled;
20263 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20264 return b;
20265 }
20266 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20267 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20268 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20269 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20270 }
20271
20272 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20273
20274 function filterRows(status, btn) {
20275 activeStatusFilter = status;
20276 deltaCurrPage = 1;
20277 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20278 b.classList.remove('active');
20279 });
20280 if (btn) btn.classList.add('active');
20281 renderDeltaPage();
20282 }
20283
20284 // ── Sorting ──────────────────────────────────────────────────────────────
20285 var sortCol = null, sortOrder = 'asc';
20286 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20287 (function() {
20288 var tbody = document.getElementById('delta-tbody');
20289 if (!tbody) return;
20290 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20291 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20292 })();
20293
20294 function parseDeltaNum(str) {
20295 if (!str || str === '—') return 0;
20296 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20297 }
20298
20299 sortHeaders.forEach(function(th) {
20300 th.addEventListener('click', function(e) {
20301 if (e.target.classList.contains('col-resize-handle')) return;
20302 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20303 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20304 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20305 th.classList.add('sort-' + sortOrder);
20306 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20307 var tbody = document.getElementById('delta-tbody');
20308 if (!tbody) return;
20309 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20310 rows.sort(function(a, b) {
20311 var va, vb;
20312 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20313 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20314 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20315 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20316 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20317 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20318 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20319 else { va = ''; vb = ''; }
20320 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20321 return va < vb ? 1 : va > vb ? -1 : 0;
20322 });
20323 rows.forEach(function(r) { tbody.appendChild(r); });
20324 deltaCurrPage = 1;
20325 renderDeltaPage();
20326 var activeBtn = document.querySelector('.tab-btn.active');
20327 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20328 if (activeBtn) activeBtn.classList.add('active');
20329 });
20330 });
20331
20332 // ── Column resize ─────────────────────────────────────────────────────────
20333 (function() {
20334 var table = document.getElementById('delta-table');
20335 if (!table) return;
20336 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20337 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20338 ths.forEach(function(th, i) {
20339 var handle = th.querySelector('.col-resize-handle');
20340 if (!handle || !cols[i]) return;
20341 var startX, startW;
20342 handle.addEventListener('mousedown', function(e) {
20343 e.stopPropagation(); e.preventDefault();
20344 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20345 handle.classList.add('dragging');
20346 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20347 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20348 document.addEventListener('mousemove', onMove);
20349 document.addEventListener('mouseup', onUp);
20350 });
20351 });
20352 })();
20353
20354 // ── Reset ─────────────────────────────────────────────────────────────────
20355 window.resetDeltaTable = function() {
20356 sortCol = null; sortOrder = 'asc';
20357 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20358 var tbody = document.getElementById('delta-tbody');
20359 if (tbody) {
20360 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20361 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20362 rows.forEach(function(r) { tbody.appendChild(r); });
20363 }
20364 var table = document.getElementById('delta-table');
20365 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20366 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20367 activeStatusFilter = 'all';
20368 deltaCurrPage = 1;
20369 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20370 var allBtn = document.querySelector('.tab-btn');
20371 if (allBtn) allBtn.classList.add('active');
20372 renderDeltaPage();
20373 };
20374
20375 renderDeltaPage();
20376
20377 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20378 (function() {
20379 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20380 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20381 });
20382 var resetBtn = document.getElementById('delta-reset-btn');
20383 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20384 var csvBtn = document.getElementById('delta-csv-btn');
20385 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20386 var xlsBtn = document.getElementById('delta-xls-btn');
20387 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20388 var chartsBtn = document.getElementById('delta-charts-btn');
20389 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20390 var ppSel = document.getElementById('per-page-sel');
20391 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20392 var pathLink = document.getElementById('project-path-link');
20393 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20394 })();
20395
20396 // ── Export helpers ────────────────────────────────────────────────────────
20397 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
20398 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20399 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);}
20400 function slocMakeXlsx(fname,sd,dr){
20401 var enc=new TextEncoder();
20402 // CRC-32 table
20403 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;}
20404 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;}
20405 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20406 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20407 // Shared string table
20408 var ss=[],si={};
20409 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20410 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20411 // Worksheet builder — each WS() call gets its own row counter R
20412 function WS(){
20413 var R=0,buf=[];
20414 function cl(c){return String.fromCharCode(65+c);}
20415 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20416 '<v>'+S(v)+'</v></c>';}
20417 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20418 (st?' s="'+st+'"':'')+'>'+
20419 '<v>'+(+v)+'</v></c>';}
20420 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20421 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20422 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20423 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20424 '<sheetFormatPr defaultRowHeight="15"/>'+
20425 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20426 return{sc:sc,nc:nc,row:row,xml:xml};
20427 }
20428 // Language breakdown
20429 var lm={};
20430 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;});
20431 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20432 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20433 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20434 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20435 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20436 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20437 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):'';}
20438 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20439 // Summary sheet
20440 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20441 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20442 r1(s1(0,proj,2));
20443 r1(s1(0,sd.bts+' → '+sd.cts,2));
20444 r1('');
20445 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20446 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))));
20447 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))));
20448 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))));
20449 r1('');
20450 r1(s1(0,'FILE CHANGES',8));
20451 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20452 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20453 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20454 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20455 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20456 if(langs.length){
20457 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20458 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20459 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)));});
20460 }
20461 r1('');r1(s1(0,'SCAN METADATA',8));
20462 r1(s1(1,_blabel)+s1(2,_clabel));
20463 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20464 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20465 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"/>');
20466 // File Delta sheet
20467 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20468 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));
20469 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)));});
20470 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20471 // Shared strings XML
20472 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20473 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20474 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20475 // XLSX file map
20476 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20477 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>',
20478 '_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>',
20479 '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>',
20480 '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>',
20481 '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>',
20482 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20483 // ZIP packer — STORED (no compression), compatible with all XLSX readers
20484 var zparts=[],zcds=[],zoff=0,znf=0;
20485 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20486 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20487 ].forEach(function(name){
20488 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20489 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]);
20490 var entry=new Uint8Array(lha.length+nb.length+sz);
20491 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20492 zparts.push(entry);
20493 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));
20494 var cde=new Uint8Array(cda.length+nb.length);
20495 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20496 zcds.push(cde);zoff+=entry.length;znf++;
20497 });
20498 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20499 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]);
20500 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20501 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20502 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20503 zout.set(new Uint8Array(ea),zpos);
20504 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20505 var xurl=URL.createObjectURL(xblob);
20506 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20507 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20508 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20509 }
20510 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;');}
20511 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20512 function getExportFilename(ext){return _exportBase+'.'+ext;}
20513
20514 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 }}'};
20515 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;}
20516 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20517 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20518 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20519 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20520 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):'';}
20521 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20522 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)]];}
20523 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20524 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;}
20525 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20526 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20527
20528 // ── Chart HTML report ─────────────────────────────────────────────────────
20529 function slocChartReport(fname, sd, dr) {
20530 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20531 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20532 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20533 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();}
20534 function px(n){return Math.round(n);}
20535 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20536 // Language map
20537 var lm={};
20538 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;});
20539 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20540
20541 // Builds onmouse* attrs for interactive tooltip on each SVG element
20542 function barTT(label,val){
20543 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
20544 }
20545
20546 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
20547 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'}];
20548 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20549 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
20550 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
20551 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20552 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"/>';}
20553 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20554 c1mets.forEach(function(m,i){
20555 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20556 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20557 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>';
20558 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))+'/>';
20559 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>';
20560 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))+'/>';
20561 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>';
20562 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>';
20563 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>';
20564 });
20565 c1+='</svg>';
20566
20567 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
20568 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'}];
20569 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20570 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
20571 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
20572 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20573 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20574 mets.forEach(function(m,i){
20575 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
20576 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
20577 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
20578 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>';
20579 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
20580 if(bw>=52){
20581 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>';
20582 }else{
20583 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
20584 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>';
20585 }
20586 });
20587 c2+='</svg>';
20588
20589 // ── Chart 3: Language Code Delta ─────────────────────────────────────
20590 var c3='';
20591 if(langs.length){
20592 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20593 var C3W=550,c3LW=124,c3FW=52;
20594 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
20595 var L3rH=30,C3H=langs.length*L3rH+20;
20596 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20597 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20598 langs.forEach(function(l,i){
20599 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
20600 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
20601 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
20602 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20603 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':''))+'/>';
20604 if(bw>=48){
20605 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>';
20606 }else{
20607 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
20608 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>';
20609 }
20610 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>';
20611 });
20612 c3+='</svg>';
20613 }
20614
20615 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
20616 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;});
20617 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20618 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
20619 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20620 var ang=-Math.PI/2;
20621 segs.forEach(function(s){
20622 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20623 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
20624 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
20625 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
20626 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
20627 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)+'%')+'/>';
20628 ang+=sw;
20629 });
20630 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>';
20631 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20632 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>';});
20633 c4+='</svg>';
20634
20635 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
20636 var ttJs='var tt=document.getElementById("ox-tt");'+
20637 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
20638 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
20639 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
20640 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
20641 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
20642 'function oxHT(){tt.style.display="none";}';
20643
20644 // body max-width keeps charts from inflating beyond design dimensions on
20645 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
20646 // each chart's height blows up proportionally, breaking the one-page layout.
20647 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;}'+
20648 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
20649 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
20650 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
20651 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
20652 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
20653 'svg{display:block;}'+
20654 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
20655 '#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;}'+
20656 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
20657 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
20658 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
20659 '<div id="ox-tt"><\/div>'+
20660 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
20661 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
20662 '<div class="two-col">'+
20663 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
20664 '<div class="leg">'+
20665 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
20666 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
20667 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
20668 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
20669 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
20670 '<\/div>'+
20671 '<div class="two-col">'+
20672 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
20673 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
20674 '<\/div>'+
20675 '<script>'+ttJs+'<\/script>'+
20676 '<\/body><\/html>';
20677 slocDownload(html, fname, 'text/html;charset=utf-8;');
20678 }
20679 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
20680 // ── Inline delta charts ────────────────────────────────────────────────────
20681 var _icTT=document.getElementById('ic-tt');
20682 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
20683 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';};
20684 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
20685 (function(){
20686 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
20687 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
20688 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();}
20689 function px(n){return Math.round(n);}
20690 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20691 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
20692 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);});}
20693 var dr=getDeltaExportRows(),sd=_sd,lm={};
20694 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;});
20695 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20696 // Chart 1: Baseline vs Current grouped bars
20697 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'}];
20698 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20699 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;
20700 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20701 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"/>';}
20702 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20703 c1mets.forEach(function(m,i){
20704 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20705 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20706 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>';
20707 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"/>';
20708 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>';
20709 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"/>';
20710 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>';
20711 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>';
20712 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>';
20713 });
20714 c1+='</svg>';
20715 // Chart 2: Delta by Metric
20716 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'}];
20717 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20718 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;
20719 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20720 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20721 mets.forEach(function(m,i){
20722 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);
20723 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>';
20724 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
20725 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>';}
20726 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>';}
20727 });
20728 c2+='</svg>';
20729 // Chart 3: Language Code Delta
20730 var c3='';
20731 if(langs.length){
20732 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20733 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;
20734 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20735 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20736 langs.forEach(function(l,i){
20737 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);
20738 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20739 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"/>';
20740 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>';}
20741 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>';}
20742 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>';
20743 });
20744 c3+='</svg>';
20745 }
20746 // Chart 4: File Change Donut
20747 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;});
20748 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20749 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;
20750 if(segs.length===1){
20751 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
20752 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
20753 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
20754 } else {
20755 segs.forEach(function(s){
20756 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20757 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);
20758 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);
20759 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"/>';
20760 ang+=sw;
20761 });
20762 }
20763 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>';
20764 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20765 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>';});
20766 c4+='</svg>';
20767 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
20768 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
20769 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);}
20770 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
20771 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
20772 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent=' /'+el.textContent.replace(/\s+/g,'');});
20773 })();
20774 </script>
20775 <script nonce="{{ csp_nonce }}">
20776 (function(){
20777 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'}];
20778 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);});}
20779 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20780 function init(){
20781 var btn=document.getElementById('settings-btn');if(!btn)return;
20782 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20783 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>';
20784 document.body.appendChild(m);
20785 var g=document.getElementById('scheme-grid');
20786 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);});
20787 var cl=document.getElementById('settings-close');
20788 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);
20789 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');});
20790 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20791 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20792 }
20793 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20794 }());
20795 </script>
20796 <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>
20797</body>
20798</html>
20799"##,
20800 ext = "html"
20801)]
20802#[allow(clippy::struct_excessive_bools)]
20804struct CompareTemplate {
20805 version: &'static str,
20806 project_label: String,
20807 baseline_git_commit: String,
20808 current_git_commit: String,
20809 baseline_run_id: String,
20810 current_run_id: String,
20811 baseline_run_id_short: String,
20812 current_run_id_short: String,
20813 baseline_timestamp: String,
20814 baseline_timestamp_utc_ms: i64,
20815 current_timestamp: String,
20816 current_timestamp_utc_ms: i64,
20817 project_path: String,
20818 baseline_code: u64,
20819 current_code: u64,
20820 code_lines_delta_str: String,
20821 code_lines_delta_class: String,
20822 baseline_files: u64,
20823 current_files: u64,
20824 files_analyzed_delta_str: String,
20825 files_analyzed_delta_class: String,
20826 baseline_comments: u64,
20827 current_comments: u64,
20828 comment_lines_delta_str: String,
20829 comment_lines_delta_class: String,
20830 code_lines_pct_str: String,
20831 files_analyzed_pct_str: String,
20832 comment_lines_pct_str: String,
20833 code_lines_added: i64,
20834 code_lines_removed: i64,
20835 new_scope: bool,
20837 churn_rate_str: String,
20838 churn_rate_class: String,
20839 scope_flag: bool,
20840 files_added: usize,
20841 files_removed: usize,
20842 files_modified: usize,
20843 files_unchanged: usize,
20844 file_rows: Vec<CompareFileDeltaRow>,
20845 baseline_git_author: Option<String>,
20846 current_git_author: Option<String>,
20847 baseline_git_branch: String,
20848 current_git_branch: String,
20849 baseline_git_tags: Option<String>,
20850 current_git_tags: Option<String>,
20851 baseline_git_commit_date: Option<String>,
20852 current_git_commit_date: Option<String>,
20853 project_name: String,
20854 submodule_options: Vec<String>,
20856 has_any_submodule_data: bool,
20858 active_submodule: Option<String>,
20860 super_scope_active: bool,
20862 csp_nonce: String,
20863 coverage_delta_card: String,
20865}
20866
20867#[derive(Template)]
20870#[template(
20871 source = r##"
20872<!doctype html>
20873<html lang="en">
20874<head>
20875 <meta charset="utf-8">
20876 <meta name="viewport" content="width=device-width, initial-scale=1">
20877 <title>OxideSLOC | Sign In</title>
20878 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20879 <style nonce="{{ csp_nonce }}">
20880 :root {
20881 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
20882 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
20883 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
20884 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
20885 }
20886 *{box-sizing:border-box;}
20887 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);}
20888 .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);}
20889 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
20890 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
20891 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
20892 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20893 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20894 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20895 .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;}
20896 @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));}}
20897 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
20898 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
20899 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20900 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
20901 .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;}
20902 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
20903 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;}
20904 input[type=password]:focus{border-color:var(--oxide);}
20905 .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;}
20906 .btn:hover{opacity:.88;}
20907 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
20908 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
20909 </style>
20910</head>
20911<body>
20912 <div class="background-watermarks" aria-hidden="true">
20913 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20914 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20915 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20916 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20917 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20918 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20919 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20920 </div>
20921 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20922<nav class="top-nav">
20923 <a class="brand" href="/">
20924 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
20925 <span class="brand-title">OxideSLOC</span>
20926 </a>
20927</nav>
20928<main class="page">
20929 <div class="card">
20930 <h1>Sign In</h1>
20931 <p class="subtitle">Enter the API key printed when the server started.</p>
20932 {% if has_error %}
20933 <div class="error">Incorrect API key — please try again.</div>
20934 {% endif %}
20935 <form method="POST" action="/auth/login">
20936 <input type="hidden" name="next" value="{{ next_url|e }}">
20937 <label for="key">API Key</label>
20938 <input id="key" type="password" name="key" autocomplete="current-password"
20939 placeholder="Paste your API key here" autofocus>
20940 <button type="submit" class="btn">Sign In</button>
20941 </form>
20942 <p class="hint">
20943 The API key was printed in the terminal when the server started.<br>
20944 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
20945 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
20946 </p>
20947 </div>
20948</main>
20949<script nonce="{{ csp_nonce }}">
20950(function() {
20951 (function randomizeWatermarks() {
20952 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20953 if (!wms.length) return;
20954 var placed = [];
20955 function tooClose(top, left) {
20956 for (var i = 0; i < placed.length; i++) {
20957 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
20958 if (dt < 16 && dl < 12) return true;
20959 }
20960 return false;
20961 }
20962 function pick(leftBand) {
20963 for (var attempt = 0; attempt < 50; attempt++) {
20964 var top = Math.random() * 88 + 2;
20965 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20966 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20967 }
20968 var top = Math.random() * 88 + 2;
20969 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20970 placed.push([top, left]); return [top, left];
20971 }
20972 var half = Math.floor(wms.length / 2);
20973 wms.forEach(function (img, i) {
20974 var pos = pick(i < half);
20975 var size = Math.floor(Math.random() * 100 + 120);
20976 var rot = (Math.random() * 360).toFixed(1);
20977 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
20978 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;
20979 });
20980 })();
20981 (function spawnCodeParticles() {
20982 var container = document.getElementById('code-particles');
20983 if (!container) return;
20984 var snippets = [
20985 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20986 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20987 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20988 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20989 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20990 ];
20991 var count = 38;
20992 for (var i = 0; i < count; i++) {
20993 (function(idx) {
20994 var el = document.createElement('span');
20995 el.className = 'code-particle';
20996 el.textContent = snippets[idx % snippets.length];
20997 var left = Math.random() * 94 + 2;
20998 var top = Math.random() * 88 + 6;
20999 var dur = (Math.random() * 10 + 9).toFixed(1);
21000 var delay = (Math.random() * 18).toFixed(1);
21001 var rot = (Math.random() * 26 - 13).toFixed(1);
21002 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21003 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21004 container.appendChild(el);
21005 })(i);
21006 }
21007 })();
21008})();
21009</script>
21010</body>
21011</html>
21012"##,
21013 ext = "html"
21014)]
21015pub(crate) struct LoginTemplate {
21016 pub(crate) csp_nonce: String,
21017 pub(crate) has_error: bool,
21018 pub(crate) next_url: String,
21019 pub(crate) lockout_threshold: u32,
21020}
21021
21022#[derive(Template)]
21025#[template(
21026 source = r##"
21027<!doctype html>
21028<html lang="en">
21029<head>
21030 <meta charset="utf-8">
21031 <meta name="viewport" content="width=device-width, initial-scale=1">
21032 <title>OxideSLOC — REST API Reference</title>
21033 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21034 <style nonce="{{ csp_nonce }}">
21035 :root {
21036 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21037 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21038 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21039 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21040 --success:#16a34a;
21041 }
21042 body.dark-theme {
21043 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21044 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21045 }
21046 *{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;}
21047 .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);}
21048 .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;}
21049 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21050 .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));}
21051 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21052 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21053 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21054 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21055 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21056 @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; } }
21057 .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;}
21058 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21059 .nav-pill.active{background:rgba(255,255,255,0.22);}
21060 .nav-dropdown{position:relative;display:inline-flex;}
21061 .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;}
21062 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21063 .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;}
21064 .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;}
21065 .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);}
21066 .nav-dropdown-menu a:last-child{border-bottom:none;}
21067 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21068 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21069 .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;}
21070 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21071 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21072 .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;}
21073 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21074 .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);}
21075 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21076 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21077 .settings-modal-body{padding:14px 16px 16px;}
21078 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21079 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21080 .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;}
21081 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21082 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21083 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21084 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21085 .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;}
21086 .tz-select:focus{border-color:var(--oxide);}
21087 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21088 .page-header{margin-bottom:28px;}
21089 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21090 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21091 .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;}
21092 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21093 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21094 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21095 .callout strong{font-weight:800;}
21096 .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;}
21097 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21098 .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;}
21099 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21100 .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;}
21101 body.dark-theme .base-url-value{color:var(--accent);}
21102 .section{margin-bottom:36px;}
21103 .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);}
21104 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21105 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21106 .ep-header:hover{background:var(--surface-2);}
21107 .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;}
21108 .method.get{background:#dcfce7;color:#166534;}
21109 .method.post{background:#dbeafe;color:#1e40af;}
21110 .method.delete{background:#fee2e2;color:#991b1b;}
21111 body.dark-theme .method.get{background:#14532d;color:#86efac;}
21112 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21113 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21114 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21115 .ep-path .param{color:var(--oxide-2);}
21116 body.dark-theme .ep-path .param{color:var(--oxide);}
21117 .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;}
21118 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21119 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21120 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21121 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21122 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21123 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21124 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21125 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21126 .ep-card.open .chevron{transform:rotate(180deg);}
21127 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21128 .ep-card.open .ep-body{display:block;}
21129 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21130 .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;}
21131 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21132 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21133 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21134 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21135 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);}
21136 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21137 table.params tr:last-child td{border-bottom:none;}
21138 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21139 .pt-type{color:var(--muted-2);font-size:12px;}
21140 .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;}
21141 .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;}
21142 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21143 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21144 details.schema{margin-bottom:14px;}
21145 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;}
21146 details.schema summary:hover{color:var(--text);}
21147 .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;}
21148 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21149 .curl-wrap{position:relative;}
21150 .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;}
21151 .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;}
21152 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21153 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21154 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21155 .webhook-note a{color:var(--accent-2);text-decoration:none;}
21156 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21157 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21158 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21159 .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;}
21160 @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));}}
21161 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21162 .site-footer a{color:var(--muted);}
21163 </style>
21164</head>
21165<body>
21166 <div class="background-watermarks" aria-hidden="true">
21167 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21168 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21169 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21170 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21171 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21172 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21173 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21174 </div>
21175 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21176 <div class="top-nav">
21177 <div class="top-nav-inner">
21178 <a class="brand" href="/">
21179 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21180 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21181 </a>
21182 <div class="nav-right">
21183 <a class="nav-pill" href="/">Home</a>
21184 <div class="nav-dropdown">
21185 <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>
21186 <div class="nav-dropdown-menu">
21187 <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>
21188 </div>
21189 </div>
21190 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21191 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21192 <div class="nav-dropdown">
21193 <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>
21194 <div class="nav-dropdown-menu">
21195 <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>
21196 </div>
21197 </div>
21198 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21199 <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>
21200 </button>
21201 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21202 <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>
21203 <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>
21204 </button>
21205 </div>
21206 </div>
21207 </div>
21208
21209 <div class="page">
21210 <div class="page-header">
21211 <h1 class="page-title">REST API Reference</h1>
21212 <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>
21213 </div>
21214
21215 {% if has_api_key %}
21216 <div class="callout key-set">
21217 <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>
21218 <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>
21219 </div>
21220 {% else %}
21221 <div class="callout no-key">
21222 <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>
21223 <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>
21224 </div>
21225 {% endif %}
21226
21227 <div class="base-url-bar">
21228 <span class="base-url-label">Base URL</span>
21229 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21230 </div>
21231
21232 <!-- Health -->
21233 <div class="section">
21234 <h2 class="section-title">Health & Status</h2>
21235 <div class="ep-card">
21236 <div class="ep-header">
21237 <span class="method get">GET</span>
21238 <span class="ep-path">/healthz</span>
21239 <span class="auth-badge public">Public</span>
21240 <span class="ep-desc">Server liveness check</span>
21241 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21242 </div>
21243 <div class="ep-body">
21244 <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>
21245 <p class="params-heading">Response</p>
21246 <div class="schema-block">200 OK
21247Content-Type: text/plain
21248
21249ok</div>
21250 <p class="curl-heading">Example</p>
21251 <div class="curl-wrap">
21252 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21253 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21254 </div>
21255 </div>
21256 </div>
21257 </div>
21258
21259 <!-- Badges -->
21260 <div class="section">
21261 <h2 class="section-title">Badges</h2>
21262 <div class="ep-card">
21263 <div class="ep-header">
21264 <span class="method get">GET</span>
21265 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21266 <span class="auth-badge public">Public</span>
21267 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21268 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21269 </div>
21270 <div class="ep-body">
21271 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21272 <p class="params-heading">Path Parameters</p>
21273 <table class="params">
21274 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21275 <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>
21276 </table>
21277 <p class="curl-heading">Example</p>
21278 <div class="curl-wrap">
21279 <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>
21280 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21281 </div>
21282 </div>
21283 </div>
21284 </div>
21285
21286 <!-- Metrics -->
21287 <div class="section">
21288 <h2 class="section-title">Metrics</h2>
21289
21290 <div class="ep-card">
21291 <div class="ep-header">
21292 <span class="method get">GET</span>
21293 <span class="ep-path">/api/metrics/latest</span>
21294 <span class="auth-badge protected">Protected</span>
21295 <span class="ep-desc">Latest scan metrics (JSON)</span>
21296 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21297 </div>
21298 <div class="ep-body">
21299 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21300 <details class="schema"><summary>Response schema</summary>
21301<div class="schema-block">{
21302 "run_id": string, // UUID
21303 "timestamp": string, // ISO-8601 UTC
21304 "project": string, // scanned root path
21305 "summary": {
21306 "files_analyzed": number,
21307 "files_skipped": number,
21308 "code_lines": number,
21309 "comment_lines": number,
21310 "blank_lines": number,
21311 "total_physical_lines": number,
21312 "functions": number,
21313 "classes": number,
21314 "variables": number,
21315 "imports": number
21316 },
21317 "languages": [
21318 { "name": string, "files": number, "code_lines": number,
21319 "comment_lines": number, "blank_lines": number,
21320 "functions": number, "classes": number,
21321 "variables": number, "imports": number }
21322 ]
21323}</div></details>
21324 <p class="curl-heading">Example</p>
21325 <div class="curl-wrap">
21326 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21327 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21328 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21329 </div>
21330 </div>
21331 </div>
21332
21333 <div class="ep-card">
21334 <div class="ep-header">
21335 <span class="method get">GET</span>
21336 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21337 <span class="auth-badge protected">Protected</span>
21338 <span class="ep-desc">Metrics for a specific run</span>
21339 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21340 </div>
21341 <div class="ep-body">
21342 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21343 <p class="params-heading">Path Parameters</p>
21344 <table class="params">
21345 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21346 <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>
21347 </table>
21348 <p class="curl-heading">Example</p>
21349 <div class="curl-wrap">
21350 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21351 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
21352 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21353 </div>
21354 </div>
21355 </div>
21356
21357 <div class="ep-card">
21358 <div class="ep-header">
21359 <span class="method get">GET</span>
21360 <span class="ep-path">/api/metrics/history</span>
21361 <span class="auth-badge protected">Protected</span>
21362 <span class="ep-desc">Paginated scan history</span>
21363 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21364 </div>
21365 <div class="ep-body">
21366 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21367 <p class="params-heading">Query Parameters</p>
21368 <table class="params">
21369 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21370 <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>
21371 <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>
21372 </table>
21373 <details class="schema"><summary>Response schema</summary>
21374<div class="schema-block">[{
21375 "run_id": string,
21376 "timestamp": string, // ISO-8601 UTC
21377 "commit": string | null,
21378 "branch": string | null,
21379 "tags": string[],
21380 "code_lines": number,
21381 "comment_lines": number,
21382 "blank_lines": number,
21383 "physical_lines": number,
21384 "files_analyzed": number,
21385 "project_label": string,
21386 "html_url": string | null
21387}]</div></details>
21388 <p class="curl-heading">Example</p>
21389 <div class="curl-wrap">
21390 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21391 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21392 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21393 </div>
21394 </div>
21395 </div>
21396
21397 <div class="ep-card">
21398 <div class="ep-header">
21399 <span class="method get">GET</span>
21400 <span class="ep-path">/api/project-history</span>
21401 <span class="auth-badge protected">Protected</span>
21402 <span class="ep-desc">Project-level scan summary</span>
21403 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21404 </div>
21405 <div class="ep-body">
21406 <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>
21407 <p class="params-heading">Query Parameters</p>
21408 <table class="params">
21409 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21410 <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>
21411 </table>
21412 <details class="schema"><summary>Response schema</summary>
21413<div class="schema-block">{
21414 "scan_count": number,
21415 "last_scan_id": string | null,
21416 "last_scan_timestamp": string | null, // ISO-8601
21417 "last_scan_code_lines": number | null,
21418 "last_git_branch": string | null,
21419 "last_git_commit": string | null
21420}</div></details>
21421 <p class="curl-heading">Example</p>
21422 <div class="curl-wrap">
21423 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21424 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21425 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21426 </div>
21427 </div>
21428 </div>
21429
21430 <div class="ep-card">
21431 <div class="ep-header">
21432 <span class="method get">GET</span>
21433 <span class="ep-path">/api/metrics/submodules</span>
21434 <span class="auth-badge protected">Protected</span>
21435 <span class="ep-desc">List known git submodules across scans</span>
21436 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21437 </div>
21438 <div class="ep-body">
21439 <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>
21440 <p class="params-heading">Query Parameters</p>
21441 <table class="params">
21442 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21443 <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>
21444 </table>
21445 <details class="schema"><summary>Response schema</summary>
21446<div class="schema-block">[{
21447 "name": string, // submodule name
21448 "relative_path": string // path relative to the project root
21449}]</div></details>
21450 <p class="curl-heading">Example</p>
21451 <div class="curl-wrap">
21452 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21453 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21454 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21455 </div>
21456 </div>
21457 </div>
21458 </div>
21459
21460 <!-- Async Run Status -->
21461 <div class="section">
21462 <h2 class="section-title">Async Run Status</h2>
21463
21464 <div class="ep-card">
21465 <div class="ep-header">
21466 <span class="method get">GET</span>
21467 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21468 <span class="auth-badge protected">Protected</span>
21469 <span class="ep-desc">Poll scan completion</span>
21470 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21471 </div>
21472 <div class="ep-body">
21473 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21474 <details class="schema"><summary>Response schema</summary>
21475<div class="schema-block">// Running
21476{ "state": "running", "elapsed_secs": number }
21477
21478// Complete
21479{ "state": "complete", "run_id": string }
21480
21481// Failed
21482{ "state": "failed", "message": string }</div></details>
21483 <p class="curl-heading">Example</p>
21484 <div class="curl-wrap">
21485 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21486 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
21487 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21488 </div>
21489 </div>
21490 </div>
21491
21492 <div class="ep-card">
21493 <div class="ep-header">
21494 <span class="method get">GET</span>
21495 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21496 <span class="auth-badge protected">Protected</span>
21497 <span class="ep-desc">Poll PDF generation readiness</span>
21498 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21499 </div>
21500 <div class="ep-body">
21501 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21502 <details class="schema"><summary>Response schema</summary>
21503<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21504 <p class="curl-heading">Example</p>
21505 <div class="curl-wrap">
21506 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21507 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
21508 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21509 </div>
21510 </div>
21511 </div>
21512
21513 <div class="ep-card">
21514 <div class="ep-header">
21515 <span class="method post">POST</span>
21516 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21517 <span class="auth-badge protected">Protected</span>
21518 <span class="ep-desc">Cancel a running scan</span>
21519 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21520 </div>
21521 <div class="ep-body">
21522 <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>
21523 <p class="curl-heading">Example</p>
21524 <div class="curl-wrap">
21525 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21526 -H "Authorization: Bearer $SLOC_API_KEY" \
21527 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
21528 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21529 </div>
21530 </div>
21531 </div>
21532 </div>
21533
21534 <!-- Scan Profiles -->
21535 <div class="section">
21536 <h2 class="section-title">Scan Profiles</h2>
21537
21538 <div class="ep-card">
21539 <div class="ep-header">
21540 <span class="method get">GET</span>
21541 <span class="ep-path">/api/scan-profiles</span>
21542 <span class="auth-badge protected">Protected</span>
21543 <span class="ep-desc">List saved scan profiles</span>
21544 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21545 </div>
21546 <div class="ep-body">
21547 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
21548 <details class="schema"><summary>Response schema</summary>
21549<div class="schema-block">{
21550 "profiles": [{
21551 "id": string, // UUID
21552 "name": string,
21553 "created_at": string, // ISO-8601
21554 "params": object
21555 }]
21556}</div></details>
21557 <p class="curl-heading">Example</p>
21558 <div class="curl-wrap">
21559 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21560 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21561 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
21562 </div>
21563 </div>
21564 </div>
21565
21566 <div class="ep-card">
21567 <div class="ep-header">
21568 <span class="method post">POST</span>
21569 <span class="ep-path">/api/scan-profiles</span>
21570 <span class="auth-badge protected">Protected</span>
21571 <span class="ep-desc">Save a scan profile</span>
21572 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21573 </div>
21574 <div class="ep-body">
21575 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
21576 <p class="params-heading">Request Body (application/json)</p>
21577 <table class="params">
21578 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21579 <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>
21580 <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>
21581 </table>
21582 <details class="schema"><summary>Response schema</summary>
21583<div class="schema-block">{ "ok": true }</div></details>
21584 <p class="curl-heading">Example</p>
21585 <div class="curl-wrap">
21586 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
21587 -H "Authorization: Bearer $SLOC_API_KEY" \
21588 -H "Content-Type: application/json" \
21589 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
21590 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21591 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
21592 </div>
21593 </div>
21594 </div>
21595
21596 <div class="ep-card">
21597 <div class="ep-header">
21598 <span class="method delete">DELETE</span>
21599 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
21600 <span class="auth-badge protected">Protected</span>
21601 <span class="ep-desc">Delete a scan profile</span>
21602 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21603 </div>
21604 <div class="ep-body">
21605 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
21606 <p class="params-heading">Path Parameters</p>
21607 <table class="params">
21608 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21609 <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>
21610 </table>
21611 <details class="schema"><summary>Response schema</summary>
21612<div class="schema-block">{ "ok": true }</div></details>
21613 <p class="curl-heading">Example</p>
21614 <div class="curl-wrap">
21615 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
21616 -H "Authorization: Bearer $SLOC_API_KEY" \
21617 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
21618 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
21619 </div>
21620 </div>
21621 </div>
21622 </div>
21623
21624 <!-- Scheduled Scans -->
21625 <div class="section">
21626 <h2 class="section-title">Scheduled Scans</h2>
21627
21628 <div class="ep-card">
21629 <div class="ep-header">
21630 <span class="method get">GET</span>
21631 <span class="ep-path">/api/schedules</span>
21632 <span class="auth-badge protected">Protected</span>
21633 <span class="ep-desc">List configured schedules</span>
21634 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21635 </div>
21636 <div class="ep-body">
21637 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
21638 <p class="curl-heading">Example</p>
21639 <div class="curl-wrap">
21640 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21641 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21642 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
21643 </div>
21644 </div>
21645 </div>
21646
21647 <div class="ep-card">
21648 <div class="ep-header">
21649 <span class="method post">POST</span>
21650 <span class="ep-path">/api/schedules</span>
21651 <span class="auth-badge protected">Protected</span>
21652 <span class="ep-desc">Create a schedule</span>
21653 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21654 </div>
21655 <div class="ep-body">
21656 <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>
21657 <p class="curl-heading">Example</p>
21658 <div class="curl-wrap">
21659 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
21660 -H "Authorization: Bearer $SLOC_API_KEY" \
21661 -H "Content-Type: application/json" \
21662 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
21663 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21664 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
21665 </div>
21666 </div>
21667 </div>
21668
21669 <div class="ep-card">
21670 <div class="ep-header">
21671 <span class="method delete">DELETE</span>
21672 <span class="ep-path">/api/schedules</span>
21673 <span class="auth-badge protected">Protected</span>
21674 <span class="ep-desc">Delete a schedule</span>
21675 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21676 </div>
21677 <div class="ep-body">
21678 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
21679 <p class="curl-heading">Example</p>
21680 <div class="curl-wrap">
21681 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
21682 -H "Authorization: Bearer $SLOC_API_KEY" \
21683 -H "Content-Type: application/json" \
21684 -d '{"id":"<schedule_id>"}' \
21685 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21686 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
21687 </div>
21688 </div>
21689 </div>
21690 </div>
21691
21692 <!-- Git Browser -->
21693 <div class="section">
21694 <h2 class="section-title">Git Browser</h2>
21695
21696 <div class="ep-card">
21697 <div class="ep-header">
21698 <span class="method get">GET</span>
21699 <span class="ep-path">/api/git/refs</span>
21700 <span class="auth-badge protected">Protected</span>
21701 <span class="ep-desc">List git refs for a repository</span>
21702 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21703 </div>
21704 <div class="ep-body">
21705 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
21706 <p class="params-heading">Query Parameters</p>
21707 <table class="params">
21708 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21709 <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>
21710 </table>
21711 <p class="curl-heading">Example</p>
21712 <div class="curl-wrap">
21713 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21714 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
21715 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
21716 </div>
21717 </div>
21718 </div>
21719
21720 <div class="ep-card">
21721 <div class="ep-header">
21722 <span class="method get">GET</span>
21723 <span class="ep-path">/api/git/scan-ref</span>
21724 <span class="auth-badge protected">Protected</span>
21725 <span class="ep-desc">SLOC-scan a specific git ref</span>
21726 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21727 </div>
21728 <div class="ep-body">
21729 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
21730 <p class="params-heading">Query Parameters</p>
21731 <table class="params">
21732 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21733 <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>
21734 <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>
21735 </table>
21736 <p class="curl-heading">Example</p>
21737 <div class="curl-wrap">
21738 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21739 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
21740 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
21741 </div>
21742 </div>
21743 </div>
21744
21745 <div class="ep-card">
21746 <div class="ep-header">
21747 <span class="method get">GET</span>
21748 <span class="ep-path">/api/git/compare-refs</span>
21749 <span class="auth-badge protected">Protected</span>
21750 <span class="ep-desc">Compare SLOC across two git refs</span>
21751 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21752 </div>
21753 <div class="ep-body">
21754 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
21755 <p class="params-heading">Query Parameters</p>
21756 <table class="params">
21757 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21758 <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>
21759 <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>
21760 <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>
21761 </table>
21762 <p class="curl-heading">Example</p>
21763 <div class="curl-wrap">
21764 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21765 "<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>
21766 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
21767 </div>
21768 </div>
21769 </div>
21770 </div>
21771
21772 <!-- Webhooks -->
21773 <div class="section">
21774 <h2 class="section-title">Webhooks</h2>
21775 <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>
21776
21777 <div class="ep-card">
21778 <div class="ep-header">
21779 <span class="method post">POST</span>
21780 <span class="ep-path">/webhooks/github</span>
21781 <span class="auth-badge hmac">HMAC</span>
21782 <span class="ep-desc">GitHub push event receiver</span>
21783 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21784 </div>
21785 <div class="ep-body">
21786 <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>
21787 <p class="params-heading">Required Headers</p>
21788 <table class="params">
21789 <tr><th>Header</th><th>Value</th></tr>
21790 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
21791 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
21792 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21793 </table>
21794 </div>
21795 </div>
21796
21797 <div class="ep-card">
21798 <div class="ep-header">
21799 <span class="method post">POST</span>
21800 <span class="ep-path">/webhooks/gitlab</span>
21801 <span class="auth-badge hmac">HMAC</span>
21802 <span class="ep-desc">GitLab push event receiver</span>
21803 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21804 </div>
21805 <div class="ep-body">
21806 <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>
21807 <p class="params-heading">Required Headers</p>
21808 <table class="params">
21809 <tr><th>Header</th><th>Value</th></tr>
21810 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
21811 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
21812 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21813 </table>
21814 </div>
21815 </div>
21816
21817 <div class="ep-card">
21818 <div class="ep-header">
21819 <span class="method post">POST</span>
21820 <span class="ep-path">/webhooks/bitbucket</span>
21821 <span class="auth-badge hmac">HMAC</span>
21822 <span class="ep-desc">Bitbucket push event receiver</span>
21823 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21824 </div>
21825 <div class="ep-body">
21826 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
21827 <p class="params-heading">Required Headers</p>
21828 <table class="params">
21829 <tr><th>Header</th><th>Value</th></tr>
21830 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
21831 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21832 </table>
21833 </div>
21834 </div>
21835 </div>
21836
21837 <!-- Config -->
21838 <div class="section">
21839 <h2 class="section-title">Config Import / Export</h2>
21840
21841 <div class="ep-card">
21842 <div class="ep-header">
21843 <span class="method get">GET</span>
21844 <span class="ep-path">/export-config</span>
21845 <span class="auth-badge protected">Protected</span>
21846 <span class="ep-desc">Export server configuration as JSON</span>
21847 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21848 </div>
21849 <div class="ep-body">
21850 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
21851 <p class="curl-heading">Example</p>
21852 <div class="curl-wrap">
21853 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21854 -o config.json \
21855 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
21856 <button class="curl-copy-btn" data-target="c-export">Copy</button>
21857 </div>
21858 </div>
21859 </div>
21860
21861 <div class="ep-card">
21862 <div class="ep-header">
21863 <span class="method post">POST</span>
21864 <span class="ep-path">/import-config</span>
21865 <span class="auth-badge protected">Protected</span>
21866 <span class="ep-desc">Import server configuration</span>
21867 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21868 </div>
21869 <div class="ep-body">
21870 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
21871 <p class="curl-heading">Example</p>
21872 <div class="curl-wrap">
21873 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
21874 -H "Authorization: Bearer $SLOC_API_KEY" \
21875 -H "Content-Type: application/json" \
21876 -d @config.json \
21877 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
21878 <button class="curl-copy-btn" data-target="c-import">Copy</button>
21879 </div>
21880 </div>
21881 </div>
21882 </div>
21883
21884 <!-- CI Ingest -->
21885 <div class="section">
21886 <h2 class="section-title">CI Ingest</h2>
21887
21888 <div class="ep-card">
21889 <div class="ep-header">
21890 <span class="method post">POST</span>
21891 <span class="ep-path">/api/ingest</span>
21892 <span class="auth-badge protected">Protected</span>
21893 <span class="ep-desc">Push a pre-computed scan result from CI</span>
21894 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21895 </div>
21896 <div class="ep-body">
21897 <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>
21898 <p class="params-heading">Query Parameters</p>
21899 <table class="params">
21900 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21901 <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>
21902 </table>
21903 <p class="params-heading">Request Body (application/json)</p>
21904 <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>
21905 <details class="schema"><summary>Response schema</summary>
21906<div class="schema-block">// 201 Created
21907{
21908 "run_id": string, // UUID of the ingested run
21909 "view_url": string // relative URL to the report page
21910}</div></details>
21911 <p class="curl-heading">Example</p>
21912 <div class="curl-wrap">
21913 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
21914 -H "Authorization: Bearer $SLOC_API_KEY" \
21915 -H "Content-Type: application/json" \
21916 -d @result.json \
21917 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
21918 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
21919 </div>
21920 </div>
21921 </div>
21922 </div>
21923
21924 <!-- Artifact Download -->
21925 <div class="section">
21926 <h2 class="section-title">Artifact Download</h2>
21927
21928 <div class="ep-card">
21929 <div class="ep-header">
21930 <span class="method get">GET</span>
21931 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
21932 <span class="auth-badge protected">Protected</span>
21933 <span class="ep-desc">Download or view a scan artifact</span>
21934 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21935 </div>
21936 <div class="ep-body">
21937 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
21938 <p class="params-heading">Path Parameters</p>
21939 <table class="params">
21940 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21941 <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>
21942 <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>
21943 </table>
21944 <p class="params-heading">Query Parameters</p>
21945 <table class="params">
21946 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21947 <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>
21948 </table>
21949 <p class="curl-heading">Example — download JSON result</p>
21950 <div class="curl-wrap">
21951 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21952 -o result.json \
21953 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
21954 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
21955 </div>
21956 </div>
21957 </div>
21958 </div>
21959
21960 <!-- Embed Widget -->
21961 <div class="section">
21962 <h2 class="section-title">Embed Widget</h2>
21963
21964 <div class="ep-card">
21965 <div class="ep-header">
21966 <span class="method get">GET</span>
21967 <span class="ep-path">/embed/summary</span>
21968 <span class="auth-badge protected">Protected</span>
21969 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
21970 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21971 </div>
21972 <div class="ep-body">
21973 <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>
21974 <p class="params-heading">Query Parameters</p>
21975 <table class="params">
21976 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21977 <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>
21978 <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>
21979 </table>
21980 <p class="curl-heading">Example</p>
21981 <div class="curl-wrap">
21982 <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"
21983 width="460" height="260" style="border:none"></iframe></pre>
21984 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
21985 </div>
21986 </div>
21987 </div>
21988 </div>
21989
21990 <!-- Confluence Integration -->
21991 <div class="section">
21992 <h2 class="section-title">Confluence Integration</h2>
21993
21994 <div class="ep-card">
21995 <div class="ep-header">
21996 <span class="method get">GET</span>
21997 <span class="ep-path">/api/confluence/config</span>
21998 <span class="auth-badge protected">Protected</span>
21999 <span class="ep-desc">Get current Confluence configuration</span>
22000 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22001 </div>
22002 <div class="ep-body">
22003 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22004 <details class="schema"><summary>Response schema</summary>
22005<div class="schema-block">{
22006 "configured": boolean,
22007 "tier": "cloud" | "server",
22008 "base_url": string,
22009 "username": string,
22010 "api_token_set": boolean,
22011 "space_key": string,
22012 "parent_page_id": string | null,
22013 "schedule_auto_post": { "<schedule_id>": boolean }
22014}</div></details>
22015 <p class="curl-heading">Example</p>
22016 <div class="curl-wrap">
22017 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22018 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22019 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22020 </div>
22021 </div>
22022 </div>
22023
22024 <div class="ep-card">
22025 <div class="ep-header">
22026 <span class="method post">POST</span>
22027 <span class="ep-path">/api/confluence/config</span>
22028 <span class="auth-badge protected">Protected</span>
22029 <span class="ep-desc">Save Confluence configuration</span>
22030 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22031 </div>
22032 <div class="ep-body">
22033 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22034 <p class="params-heading">Request Body (application/json)</p>
22035 <table class="params">
22036 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22037 <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>
22038 <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>
22039 <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>
22040 <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>
22041 <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>
22042 <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>
22043 <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>
22044 </table>
22045 <details class="schema"><summary>Response schema</summary>
22046<div class="schema-block">{ "ok": true }</div></details>
22047 <p class="curl-heading">Example</p>
22048 <div class="curl-wrap">
22049 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22050 -H "Authorization: Bearer $SLOC_API_KEY" \
22051 -H "Content-Type: application/json" \
22052 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22053 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22054 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22055 </div>
22056 </div>
22057 </div>
22058
22059 <div class="ep-card">
22060 <div class="ep-header">
22061 <span class="method post">POST</span>
22062 <span class="ep-path">/api/confluence/test</span>
22063 <span class="auth-badge protected">Protected</span>
22064 <span class="ep-desc">Test Confluence connection</span>
22065 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22066 </div>
22067 <div class="ep-body">
22068 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22069 <details class="schema"><summary>Response schema</summary>
22070<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22071 <p class="curl-heading">Example</p>
22072 <div class="curl-wrap">
22073 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22074 -H "Authorization: Bearer $SLOC_API_KEY" \
22075 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22076 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22077 </div>
22078 </div>
22079 </div>
22080
22081 <div class="ep-card">
22082 <div class="ep-header">
22083 <span class="method post">POST</span>
22084 <span class="ep-path">/api/confluence/post</span>
22085 <span class="auth-badge protected">Protected</span>
22086 <span class="ep-desc">Publish a scan report to Confluence</span>
22087 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22088 </div>
22089 <div class="ep-body">
22090 <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>
22091 <p class="params-heading">Request Body (application/json)</p>
22092 <table class="params">
22093 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22094 <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>
22095 <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>
22096 <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>
22097 </table>
22098 <details class="schema"><summary>Response schema</summary>
22099<div class="schema-block">// 200 OK
22100{ "ok": true, "page_id": string }
22101
22102// 400 / 502 on error
22103{ "ok": false, "error": string }</div></details>
22104 <p class="curl-heading">Example</p>
22105 <div class="curl-wrap">
22106 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22107 -H "Authorization: Bearer $SLOC_API_KEY" \
22108 -H "Content-Type: application/json" \
22109 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
22110 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22111 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22112 </div>
22113 </div>
22114 </div>
22115
22116 <div class="ep-card">
22117 <div class="ep-header">
22118 <span class="method get">GET</span>
22119 <span class="ep-path">/api/confluence/wiki-markup</span>
22120 <span class="auth-badge protected">Protected</span>
22121 <span class="ep-desc">Get Confluence wiki markup for a run</span>
22122 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22123 </div>
22124 <div class="ep-body">
22125 <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>
22126 <p class="params-heading">Query Parameters</p>
22127 <table class="params">
22128 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22129 <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>
22130 </table>
22131 <p class="curl-heading">Example</p>
22132 <div class="curl-wrap">
22133 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22134 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
22135 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22136 </div>
22137 </div>
22138 </div>
22139 </div>
22140
22141 <!-- Authentication -->
22142 <div class="section">
22143 <h2 class="section-title">Authentication</h2>
22144 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22145
22146 <div class="ep-card">
22147 <div class="ep-header">
22148 <span class="method get">GET</span>
22149 <span class="ep-path">/auth/login</span>
22150 <span class="auth-badge public">Public</span>
22151 <span class="ep-desc">Login page</span>
22152 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22153 </div>
22154 <div class="ep-body">
22155 <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>
22156 <p class="params-heading">Query Parameters</p>
22157 <table class="params">
22158 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22159 <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>
22160 <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>
22161 </table>
22162 </div>
22163 </div>
22164
22165 <div class="ep-card">
22166 <div class="ep-header">
22167 <span class="method post">POST</span>
22168 <span class="ep-path">/auth/login</span>
22169 <span class="auth-badge public">Public</span>
22170 <span class="ep-desc">Submit credentials and get a session cookie</span>
22171 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22172 </div>
22173 <div class="ep-body">
22174 <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>
22175 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22176 <table class="params">
22177 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22178 <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>
22179 <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>
22180 </table>
22181 <p class="curl-heading">Example</p>
22182 <div class="curl-wrap">
22183 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22184 -d "key=$SLOC_API_KEY&next=/" \
22185 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22186 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22187 </div>
22188 </div>
22189 </div>
22190 </div>
22191
22192 <!-- Coverage Suggestion -->
22193 <div class="section">
22194 <h2 class="section-title">Coverage Suggestion</h2>
22195
22196 <div class="ep-card">
22197 <div class="ep-header">
22198 <span class="method get">GET</span>
22199 <span class="ep-path">/api/suggest-coverage</span>
22200 <span class="auth-badge protected">Protected</span>
22201 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22202 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22203 </div>
22204 <div class="ep-body">
22205 <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>
22206 <p class="params-heading">Query Parameters</p>
22207 <table class="params">
22208 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22209 <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>
22210 </table>
22211 <details class="schema"><summary>Response schema</summary>
22212<div class="schema-block">{
22213 "found": string | null, // absolute path to the coverage file, if detected
22214 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22215 "hint": string | null // shell command to generate coverage if not found
22216}</div></details>
22217 <p class="curl-heading">Example</p>
22218 <div class="curl-wrap">
22219 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22220 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22221 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22222 </div>
22223 </div>
22224 </div>
22225 </div>
22226
22227 </div>
22228
22229 <footer class="site-footer">
22230 local code analysis - metrics, history and reports
22231 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22232 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22233 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22234 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22235 · <a href="/api-docs" rel="noopener">REST API</a>
22236 </footer>
22237
22238 <script nonce="{{ csp_nonce }}">
22239 (function () {
22240 var base = window.location.origin;
22241 document.getElementById('base-url').textContent = base;
22242 document.querySelectorAll('.base-url-slot').forEach(function (el) {
22243 el.textContent = base;
22244 });
22245
22246 document.querySelectorAll('.ep-header').forEach(function (hdr) {
22247 hdr.addEventListener('click', function () {
22248 hdr.closest('.ep-card').classList.toggle('open');
22249 });
22250 });
22251
22252 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22253 btn.addEventListener('click', function () {
22254 var targetId = btn.dataset.target;
22255 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22256 if (!pre) return;
22257 navigator.clipboard.writeText(pre.textContent).then(function () {
22258 btn.textContent = 'Copied!';
22259 btn.classList.add('copied');
22260 setTimeout(function () {
22261 btn.textContent = 'Copy';
22262 btn.classList.remove('copied');
22263 }, 2000);
22264 });
22265 });
22266 });
22267
22268 var storageKey = 'oxide-sloc-theme';
22269 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22270 var themeBtn = document.getElementById('theme-toggle');
22271 if (themeBtn) {
22272 themeBtn.addEventListener('click', function () {
22273 var dark = document.body.classList.toggle('dark-theme');
22274 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22275 });
22276 }
22277 (function() {
22278 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'}];
22279 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);});}
22280 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22281 var btn=document.getElementById('settings-btn');if(!btn)return;
22282 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22283 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>';
22284 document.body.appendChild(m);
22285 var g=document.getElementById('scheme-grid');
22286 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);});
22287 var cl=document.getElementById('settings-close');
22288 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);
22289 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');});
22290 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22291 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22292 })();
22293 (function randomizeWatermarks() {
22294 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22295 if (!wms.length) return;
22296 var placed = [];
22297 function tooClose(top, left) {
22298 for (var i = 0; i < placed.length; i++) {
22299 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22300 if (dt < 16 && dl < 12) return true;
22301 }
22302 return false;
22303 }
22304 function pick(leftBand) {
22305 for (var attempt = 0; attempt < 50; attempt++) {
22306 var top = Math.random() * 88 + 2;
22307 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22308 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22309 }
22310 var top = Math.random() * 88 + 2;
22311 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22312 placed.push([top, left]); return [top, left];
22313 }
22314 var half = Math.floor(wms.length / 2);
22315 wms.forEach(function (img, i) {
22316 var pos = pick(i < half);
22317 var size = Math.floor(Math.random() * 100 + 120);
22318 var rot = (Math.random() * 360).toFixed(1);
22319 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22320 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;
22321 });
22322 })();
22323 (function spawnCodeParticles() {
22324 var container = document.getElementById('code-particles');
22325 if (!container) return;
22326 var snippets = [
22327 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22328 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22329 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22330 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22331 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22332 ];
22333 var count = 38;
22334 for (var i = 0; i < count; i++) {
22335 (function(idx) {
22336 var el = document.createElement('span');
22337 el.className = 'code-particle';
22338 el.textContent = snippets[idx % snippets.length];
22339 var left = Math.random() * 94 + 2;
22340 var top = Math.random() * 88 + 6;
22341 var dur = (Math.random() * 10 + 9).toFixed(1);
22342 var delay = (Math.random() * 18).toFixed(1);
22343 var rot = (Math.random() * 26 - 13).toFixed(1);
22344 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22345 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22346 container.appendChild(el);
22347 })(i);
22348 }
22349 })();
22350 }());
22351 </script>
22352</body>
22353</html>
22354"##,
22355 ext = "html"
22356)]
22357struct ApiDocsTemplate {
22358 has_api_key: bool,
22359 csp_nonce: String,
22360 version: &'static str,
22361}