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::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
59use sloc_git::ScheduleStore;
60
61#[derive(Clone)]
62pub(crate) struct CspNonce(pub(crate) String);
63
64static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
65
66use sloc_core::{
67 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
68 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
69};
70use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
71const MAX_CONCURRENT_ANALYSES: usize = 4;
72
73#[cfg(all(target_os = "windows", feature = "native-dialog"))]
81#[allow(clippy::upper_case_acronyms)]
82mod win_dialog_focus {
83 use std::mem::size_of;
84
85 type HWND = *mut core::ffi::c_void;
86 type DWORD = u32;
87 type UINT = u32;
88 type BOOL = i32;
89
90 #[repr(C)]
94 #[allow(non_snake_case)]
95 struct FLASHWINFO {
96 cbSize: UINT,
97 hwnd: HWND,
98 dwFlags: DWORD,
99 uCount: UINT,
100 dwTimeout: DWORD,
101 }
102
103 const FLASHW_ALL: DWORD = 0x3;
104 const FLASHW_TIMERNOFG: DWORD = 0xC;
105
106 #[link(name = "user32")]
107 extern "system" {
108 fn GetForegroundWindow() -> HWND;
109 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
110 fn BringWindowToTop(hWnd: HWND) -> BOOL;
111 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
112 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
113 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
114 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
115 }
116
117 #[link(name = "kernel32")]
118 extern "system" {
119 fn GetCurrentThreadId() -> DWORD;
120 }
121
122 pub fn attach_to_foreground() -> DWORD {
127 unsafe {
128 let fg_hwnd = GetForegroundWindow();
129 if fg_hwnd.is_null() {
130 return 0;
131 }
132 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
133 let my_tid = GetCurrentThreadId();
134 if fg_tid == my_tid {
135 return 0;
136 }
137 AttachThreadInput(my_tid, fg_tid, 1);
138 fg_tid
139 }
140 }
141
142 pub fn detach_from_foreground(fg_tid: DWORD) {
144 if fg_tid == 0 {
145 return;
146 }
147 unsafe {
148 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
149 }
150 }
151
152 pub fn flash_dialog_when_ready(title: String) {
156 std::thread::spawn(move || {
157 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
158 for _ in 0..40 {
159 std::thread::sleep(std::time::Duration::from_millis(80));
160 unsafe {
161 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
162 if !hwnd.is_null() {
163 SetForegroundWindow(hwnd);
164 BringWindowToTop(hwnd);
165 #[allow(non_snake_case)]
166 FlashWindowEx(&FLASHWINFO {
167 #[allow(clippy::cast_possible_truncation)]
170 cbSize: size_of::<FLASHWINFO>() as UINT,
171 hwnd,
172 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
173 uCount: 3,
174 dwTimeout: 0,
175 });
176 break;
177 }
178 }
179 }
180 });
181 }
182}
183
184pub(crate) struct IpRateLimiter {
187 window: Duration,
188 max_requests: usize,
189 pub(crate) auth_lockout_threshold: u32,
190 auth_lockout_window: Duration,
191 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
192 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
193}
194
195impl IpRateLimiter {
196 pub(crate) fn new(
197 window: Duration,
198 max_requests: usize,
199 auth_lockout_threshold: u32,
200 auth_lockout_window: Duration,
201 ) -> Self {
202 Self {
203 window,
204 max_requests,
205 auth_lockout_threshold,
206 auth_lockout_window,
207 state: std::sync::Mutex::new(HashMap::new()),
208 auth_failures: std::sync::Mutex::new(HashMap::new()),
209 }
210 }
211
212 #[allow(clippy::significant_drop_tightening)]
215 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
216 let now = Instant::now();
217 let cutoff = now.checked_sub(self.window).unwrap_or(now);
218 let mut state = self
219 .state
220 .lock()
221 .unwrap_or_else(std::sync::PoisonError::into_inner);
222 if state.len() > 10_000 {
223 state.retain(|_, bucket| {
224 while bucket.front().is_some_and(|t| *t <= cutoff) {
225 bucket.pop_front();
226 }
227 !bucket.is_empty()
228 });
229 }
230 let bucket = state.entry(ip).or_default();
231 while bucket.front().is_some_and(|t| *t <= cutoff) {
232 bucket.pop_front();
233 }
234 if bucket.len() >= self.max_requests {
235 false
236 } else {
237 bucket.push_back(now);
238 true
239 }
240 }
241
242 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
243 let now = Instant::now();
244 let mut map = self
245 .auth_failures
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 map.entry(ip)
249 .and_modify(|e| {
250 e.0 += 1;
251 e.1 = now;
252 })
253 .or_insert_with(|| (1, now));
254 }
255
256 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
257 let mut map = self
258 .auth_failures
259 .lock()
260 .unwrap_or_else(std::sync::PoisonError::into_inner);
261 let expired = map
262 .get(&ip)
263 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
264 if expired {
265 map.remove(&ip);
266 return false;
267 }
268 map.get(&ip)
269 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
270 }
271
272 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
273 let map = self
274 .auth_failures
275 .lock()
276 .unwrap_or_else(std::sync::PoisonError::into_inner);
277 map.get(&ip).map_or(0, |e| {
278 self.auth_lockout_window
279 .checked_sub(e.1.elapsed())
280 .map_or(0, |r| r.as_secs())
281 })
282 }
283
284 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
285 tokio::spawn(async move {
286 let mut interval = tokio::time::interval(Duration::from_mins(1));
287 interval.tick().await; loop {
289 interval.tick().await;
290 let now = Instant::now();
291 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
292 {
293 let mut state = limiter
294 .state
295 .lock()
296 .unwrap_or_else(std::sync::PoisonError::into_inner);
297 state.retain(|_, bucket| {
298 while bucket.front().is_some_and(|t| *t <= cutoff) {
299 bucket.pop_front();
300 }
301 !bucket.is_empty()
302 });
303 }
304 {
305 let mut auth = limiter
306 .auth_failures
307 .lock()
308 .unwrap_or_else(std::sync::PoisonError::into_inner);
309 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
310 }
311 }
312 });
313 }
314}
315
316#[derive(Clone, Debug, Default)]
318struct RunResultContext {
319 prev_entry: Option<RegistryEntry>,
320 prev_scan_count: usize,
321 project_path: String,
322}
323
324#[derive(Clone)]
326enum AsyncRunState {
327 Running {
328 started_at: std::time::Instant,
329 cancel_token: Arc<std::sync::atomic::AtomicBool>,
330 },
331 Complete {
333 run_id: String,
334 },
335 Failed {
336 message: String,
337 },
338 Cancelled,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
344struct ScanProfile {
345 id: String,
346 name: String,
347 created_at: String,
348 params: serde_json::Value,
350}
351
352#[derive(Debug, Clone, Default, Serialize, Deserialize)]
353struct ScanProfileStore {
354 profiles: Vec<ScanProfile>,
355}
356
357impl ScanProfileStore {
358 fn load(path: &std::path::Path) -> Self {
359 fs::read_to_string(path)
360 .ok()
361 .and_then(|s| serde_json::from_str(&s).ok())
362 .unwrap_or_default()
363 }
364
365 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
366 if let Some(parent) = path.parent() {
367 fs::create_dir_all(parent)?;
368 }
369 let json = serde_json::to_string_pretty(self)?;
370 fs::write(path, json)?;
371 Ok(())
372 }
373}
374
375#[derive(Clone)]
376pub(crate) struct AppState {
377 pub(crate) base_config: AppConfig,
378 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
379 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
380 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
381 pub(crate) registry_path: PathBuf,
382 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
383 pub(crate) server_mode: bool,
384 pub(crate) tls_enabled: bool,
385 pub(crate) api_keys: Vec<secrecy::Secret<String>>,
386 pub(crate) rate_limiter: Arc<IpRateLimiter>,
387 pub(crate) trust_proxy: bool,
388 pub(crate) git_clones_dir: PathBuf,
390 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
392 pub(crate) schedules_path: PathBuf,
393 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
395 pub(crate) scan_profiles_path: PathBuf,
396 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
397 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
399 pub(crate) confluence_path: PathBuf,
400 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
402 pub(crate) watched_dirs_path: PathBuf,
403}
404
405type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
406
407#[derive(Clone, Debug)]
410pub(crate) struct RunArtifacts {
411 output_dir: PathBuf,
412 html_path: Option<PathBuf>,
413 pdf_path: Option<PathBuf>,
414 json_path: Option<PathBuf>,
415 csv_path: Option<PathBuf>,
416 xlsx_path: Option<PathBuf>,
417 scan_config_path: Option<PathBuf>,
418 report_title: String,
419 result_context: RunResultContext,
420}
421
422#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
424 let protected = Router::new()
425 .route("/", get(splash))
426 .route("/scan-setup", get(scan_setup_handler))
427 .route("/scan", get(index))
428 .route("/analyze", post(analyze_handler))
429 .route("/preview", get(preview_handler))
430 .route("/api/suggest-coverage", get(api_suggest_coverage))
431 .route("/pick-directory", get(pick_directory_handler))
432 .route("/open-path", get(open_path_handler))
433 .route("/pick-file", get(pick_file_handler))
434 .route("/locate-report", post(locate_report_handler))
435 .route("/locate-reports-dir", post(locate_reports_dir_handler))
436 .route("/relocate-scan", post(relocate_scan_handler))
437 .route("/watched-dirs/add", post(add_watched_dir_handler))
438 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
439 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
440 .route("/view-reports", get(history_handler))
441 .route("/compare-scans", get(compare_select_handler))
442 .route("/compare", get(compare_handler))
443 .route("/images/{folder}/{file}", get(image_handler))
444 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
445 .route("/api/metrics/latest", get(api_metrics_latest_handler))
446 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
447 .route("/api/metrics/history", get(api_metrics_history_handler))
448 .route(
449 "/api/metrics/submodules",
450 get(api_metrics_submodules_handler),
451 )
452 .route("/api/ingest", post(api_ingest_handler))
453 .route("/api/project-history", get(project_history_handler))
454 .route("/trend-reports", get(trend_report_handler))
455 .route("/test-metrics", get(test_metrics_handler))
456 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
457 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
458 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
459 .route("/runs/result/{run_id}", get(async_run_result_handler))
460 .route("/embed/summary", get(embed_handler))
461 .route("/git-browser", get(git_browser::git_browser_handler))
463 .route("/api/git/refs", get(git_browser::api_list_refs))
464 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
465 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
466 .route("/export-config", get(export_config_handler))
468 .route("/import-config", post(import_config_handler))
469 .route("/api/scan-profiles", get(api_list_scan_profiles))
471 .route("/api/scan-profiles", post(api_save_scan_profile))
472 .route(
473 "/api/scan-profiles/{id}",
474 axum::routing::delete(api_delete_scan_profile),
475 )
476 .route("/integrations", get(integrations::integrations_handler))
478 .route(
479 "/webhook-setup",
480 get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
481 )
482 .route(
483 "/confluence-setup",
484 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
485 )
486 .route("/api/schedules", get(git_webhook::api_list_schedules))
487 .route("/api/schedules", post(git_webhook::api_create_schedule))
488 .route(
489 "/api/schedules",
490 axum::routing::delete(git_webhook::api_delete_schedule),
491 )
492 .route(
493 "/api/confluence/config",
494 get(confluence::api_get_confluence_config),
495 )
496 .route(
497 "/api/confluence/config",
498 post(confluence::api_save_confluence_config),
499 )
500 .route(
501 "/api/confluence/test",
502 post(confluence::api_test_confluence),
503 )
504 .route(
505 "/api/confluence/post",
506 post(confluence::api_post_to_confluence),
507 )
508 .route(
509 "/api/confluence/wiki-markup",
510 get(confluence::api_wiki_markup),
511 )
512 .route("/api-docs", get(api_docs_handler))
514 .route_layer(middleware::from_fn_with_state(
515 state.clone(),
516 auth::require_api_key,
517 ));
518
519 protected
520 .route("/healthz", get(healthz))
521 .route("/badge/{metric}", get(badge_handler))
522 .route("/static/chart.js", get(chart_js_handler))
523 .route("/auth/login", get(auth::auth_login_get))
524 .route("/auth/login", post(auth::auth_login_post))
525 .route("/webhooks/github", post(git_webhook::handle_github_webhook))
527 .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
528 .route(
529 "/webhooks/bitbucket",
530 post(git_webhook::handle_bitbucket_webhook),
531 )
532 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
533 .layer(middleware::from_fn_with_state(
534 state.clone(),
535 add_security_headers,
536 ))
537 .layer(build_cors_layer(state.server_mode))
538 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
539 .with_state(state)
540}
541
542pub fn make_test_router() -> Router {
544 let tmp = std::env::temp_dir().join("sloc_test");
545 let state = AppState {
546 base_config: AppConfig::default(),
547 artifacts: Arc::new(Mutex::new(HashMap::new())),
548 async_runs: Arc::new(Mutex::new(HashMap::new())),
549 registry: Arc::new(Mutex::new(ScanRegistry::default())),
550 registry_path: tmp.join("registry.json"),
551 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
552 server_mode: false,
553 tls_enabled: false,
554 api_keys: vec![],
555 rate_limiter: Arc::new(IpRateLimiter::new(
556 Duration::from_mins(1),
557 600,
558 10,
559 Duration::from_hours(1),
560 )),
561 trust_proxy: false,
562 git_clones_dir: tmp.join("git-clones"),
563 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
564 schedules_path: tmp.join("schedules.json"),
565 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
566 scan_profiles_path: tmp.join("scan_profiles.json"),
567 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
568 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
569 confluence_path: tmp.join("confluence_config.json"),
570 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
571 watched_dirs_path: tmp.join("watched_dirs.json"),
572 };
573 build_router(state)
574}
575
576pub fn make_test_router_with_key(api_key: &str) -> Router {
578 let tmp = std::env::temp_dir().join("sloc_test_key");
579 let state = AppState {
580 base_config: AppConfig::default(),
581 artifacts: Arc::new(Mutex::new(HashMap::new())),
582 async_runs: Arc::new(Mutex::new(HashMap::new())),
583 registry: Arc::new(Mutex::new(ScanRegistry::default())),
584 registry_path: tmp.join("registry.json"),
585 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
586 server_mode: false,
587 tls_enabled: false,
588 api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
589 rate_limiter: Arc::new(IpRateLimiter::new(
590 Duration::from_mins(1),
591 600,
592 10,
593 Duration::from_hours(1),
594 )),
595 trust_proxy: false,
596 git_clones_dir: tmp.join("git-clones"),
597 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
598 schedules_path: tmp.join("schedules.json"),
599 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
600 scan_profiles_path: tmp.join("scan_profiles.json"),
601 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
602 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
603 confluence_path: tmp.join("confluence_config.json"),
604 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
605 watched_dirs_path: tmp.join("watched_dirs.json"),
606 };
607 build_router(state)
608}
609
610#[allow(clippy::too_many_lines)]
621pub async fn serve(config: AppConfig) -> Result<()> {
622 let bind_address = config.web.bind_address.clone();
623 let server_mode = config.web.server_mode;
624 let output_root = resolve_output_root(None);
625 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
627 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
628 let mut registry = ScanRegistry::load(®istry_path);
629 registry.prune_stale();
630 let _ = registry.save(®istry_path);
631
632 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
633 .or_else(|_| std::env::var("SLOC_API_KEY"))
634 .unwrap_or_default()
635 .split(',')
636 .map(str::trim)
637 .filter(|s| !s.is_empty())
638 .map(|s| secrecy::Secret::new(s.to_owned()))
639 .collect();
640 if server_mode && api_keys.is_empty() {
641 println!(
642 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
643 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
644 );
645 }
646
647 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
648 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
649 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
650 if server_mode && !tls_enabled {
651 println!(
652 "WARNING: TLS is not configured. Traffic is cleartext. \
653 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
654 or terminate TLS at a reverse proxy (nginx, caddy)."
655 );
656 }
657 if server_mode {
658 println!(
659 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
660 to restrict cross-origin access (comma-separated)."
661 );
662 }
663 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
664 if trust_proxy {
665 println!(
666 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
667 Only set this when oxide-sloc is behind a trusted reverse proxy."
668 );
669 }
670
671 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
672 .ok()
673 .and_then(|v| v.parse::<u32>().ok())
674 .unwrap_or(10);
675 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
676 .ok()
677 .and_then(|v| v.parse::<u64>().ok())
678 .unwrap_or(3600);
679 let rate_limiter = Arc::new(IpRateLimiter::new(
681 Duration::from_mins(1),
682 600,
683 auth_lockout_threshold,
684 Duration::from_secs(auth_lockout_secs),
685 ));
686 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
687
688 let git_clones_dir = resolve_git_clones_dir(&output_root);
689 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
690 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
691 let schedules = ScheduleStore::load(&schedules_path);
692 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
693 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
694 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
695 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
696 |_| output_root.join("confluence_config.json"),
697 PathBuf::from,
698 );
699 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
700 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
701 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
702 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
703
704 let state = AppState {
705 base_config: config,
706 artifacts: Arc::new(Mutex::new(HashMap::new())),
707 async_runs: Arc::new(Mutex::new(HashMap::new())),
708 registry: Arc::new(Mutex::new(registry)),
709 registry_path,
710 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
711 server_mode,
712 tls_enabled,
713 api_keys,
714 rate_limiter,
715 trust_proxy,
716 git_clones_dir,
717 schedules: Arc::new(Mutex::new(schedules)),
718 schedules_path,
719 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
720 scan_profiles_path,
721 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
722 confluence: Arc::new(Mutex::new(confluence)),
723 confluence_path,
724 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
725 watched_dirs_path,
726 };
727
728 restart_poll_schedules(&state).await;
729
730 let app = build_router(state.clone());
731
732 let preferred: SocketAddr = bind_address
737 .parse()
738 .with_context(|| format!("invalid bind address: {bind_address}"))?;
739 let (listener, addr) = {
740 let candidates = (0u16..=9).map(|offset| {
741 let mut a = preferred;
742 a.set_port(preferred.port().saturating_add(offset));
743 a
744 });
745 let mut found = None;
746 for candidate in candidates {
747 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
748 found = Some((l, candidate));
749 break;
750 }
751 }
752 found.ok_or_else(|| {
753 anyhow::anyhow!(
754 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
755 bind_address,
756 preferred.port(),
757 preferred.port().saturating_add(9)
758 )
759 })?
760 };
761 if addr != preferred {
762 eprintln!(
763 "NOTE: port {} is blocked by a system socket (Windows zombie); \
764 using {} instead.",
765 preferred.port(),
766 addr.port()
767 );
768 }
769
770 if tls_enabled {
771 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
772 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
773 let tls_config = build_tls_config(&cert_path, &key_path)
774 .context("failed to load TLS certificate/key")?;
775 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
776
777 let url = format!("https://{addr}/");
778 println!("OxideSLOC server running at {url} (TLS)");
779 println!("Use Ctrl+C to stop.");
780
781 return serve_tls(listener, app, acceptor, server_mode).await;
782 }
783
784 let url = format!("http://{addr}/");
785 log_startup_url(&url, server_mode);
786
787 axum::serve(
788 listener,
789 app.into_make_service_with_connect_info::<SocketAddr>(),
790 )
791 .with_graceful_shutdown(shutdown_signal(server_mode))
792 .await
793 .context("web server terminated unexpectedly")
794}
795
796fn primary_lan_ip() -> Option<String> {
800 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
801 socket.connect("8.8.8.8:80").ok()?;
802 let addr = socket.local_addr().ok()?;
803 let ip = addr.ip();
804 if ip.is_loopback() {
805 return None;
806 }
807 Some(ip.to_string())
808}
809
810fn log_startup_url(url: &str, server_mode: bool) {
812 if server_mode {
813 println!("OxideSLOC server running at {url}");
814 println!("Use Ctrl+C to stop.");
815 } else {
816 println!("OxideSLOC local web UI running at {url}");
817 println!("Press Ctrl+C to stop the server.");
818 let open_url = url.to_owned();
819 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
820 }
821}
822
823fn open_browser_tab(url: &str) {
825 #[cfg(target_os = "windows")]
826 let _ = std::process::Command::new("cmd")
827 .args(["/c", "start", "", url])
828 .stdout(Stdio::null())
829 .stderr(Stdio::null())
830 .spawn();
831 #[cfg(target_os = "macos")]
832 let _ = std::process::Command::new("open")
833 .arg(url)
834 .stdout(Stdio::null())
835 .stderr(Stdio::null())
836 .spawn();
837 #[cfg(target_os = "linux")]
838 let _ = std::process::Command::new("xdg-open")
839 .arg(url)
840 .stdout(Stdio::null())
841 .stderr(Stdio::null())
842 .spawn();
843}
844
845async fn shutdown_signal(server_mode: bool) {
847 if tokio::signal::ctrl_c().await.is_ok() {
848 println!();
849 if server_mode {
850 println!("Shutting down OxideSLOC server...");
851 } else {
852 println!("Shutting down OxideSLOC local web UI...");
853 }
854 println!("Server stopped cleanly.");
855 }
856}
857
858fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
860 use rustls_pemfile::{certs, private_key};
861 use std::io::BufReader;
862
863 let cert_bytes =
864 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
865 let key_bytes =
866 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
867
868 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
869 .collect::<std::result::Result<_, _>>()
870 .context("failed to parse TLS certificates")?;
871
872 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
873 .context("failed to parse TLS private key")?
874 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
875
876 rustls::ServerConfig::builder()
877 .with_no_client_auth()
878 .with_single_cert(cert_chain, key)
879 .context("failed to build TLS server config")
880}
881
882async fn serve_tls(
884 listener: tokio::net::TcpListener,
885 app: Router,
886 acceptor: tokio_rustls::TlsAcceptor,
887 server_mode: bool,
888) -> Result<()> {
889 use hyper_util::rt::{TokioExecutor, TokioIo};
890 use hyper_util::server::conn::auto::Builder as ConnBuilder;
891 use hyper_util::service::TowerToHyperService;
892 use tower::{Service, ServiceExt};
893
894 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
895
896 loop {
897 tokio::select! {
898 biased;
899 _ = tokio::signal::ctrl_c() => {
900 println!();
901 if server_mode {
902 println!("Shutting down OxideSLOC server...");
903 } else {
904 println!("Shutting down OxideSLOC local web UI...");
905 }
906 println!("Server stopped cleanly.");
907 return Ok(());
908 }
909 result = listener.accept() => {
910 let (tcp, peer_addr) = result.context("TLS accept failed")?;
911 let acceptor = acceptor.clone();
912 let mut factory = make_svc.clone();
913
914 tokio::spawn(async move {
915 let tls = match acceptor.accept(tcp).await {
916 Ok(s) => s,
917 Err(e) => {
918 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
919 return;
920 }
921 };
922 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
923 Ok(f) => match Service::call(f, peer_addr).await {
924 Ok(s) => s,
925 Err(_) => return,
926 },
927 Err(_) => return,
928 };
929 let io = TokioIo::new(tls);
930 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
931 .serve_connection(io, TowerToHyperService::new(svc))
932 .await
933 {
934 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
935 }
936 });
937 }
938 }
939 }
940}
941
942fn build_cors_layer(server_mode: bool) -> CorsLayer {
945 if server_mode {
946 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
947 .unwrap_or_default()
948 .split(',')
949 .filter(|s| !s.is_empty())
950 .filter_map(|s| s.trim().parse().ok())
951 .collect();
952 if allowed.is_empty() {
953 return CorsLayer::new();
954 }
955 CorsLayer::new()
956 .allow_origin(AllowOrigin::list(allowed))
957 .allow_methods(AllowMethods::list([
958 axum::http::Method::GET,
959 axum::http::Method::POST,
960 ]))
961 .allow_headers(AllowHeaders::list([
962 axum::http::header::AUTHORIZATION,
963 axum::http::header::CONTENT_TYPE,
964 ]))
965 } else {
966 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
967 let s = origin.to_str().unwrap_or("");
968 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
969 }))
970 }
971}
972
973async fn add_security_headers(
974 State(state): State<AppState>,
975 mut req: Request<Body>,
976 next: Next,
977) -> Response {
978 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
979 req.extensions_mut().insert(CspNonce(nonce.clone()));
980 let mut resp = next.run(req).await;
981 let h = resp.headers_mut();
982 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
983 h.insert(
984 "X-Content-Type-Options",
985 HeaderValue::from_static("nosniff"),
986 );
987 h.insert(
988 "Referrer-Policy",
989 HeaderValue::from_static("strict-origin-when-cross-origin"),
990 );
991 let csp = format!(
992 "default-src 'self'; \
993 style-src 'self' 'unsafe-inline'; \
994 img-src 'self' data: blob:; \
995 script-src 'self' 'nonce-{nonce}'; \
996 font-src 'self' data:; \
997 object-src 'none'; \
998 frame-ancestors 'none'"
999 );
1000 h.insert(
1001 "Content-Security-Policy",
1002 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1003 HeaderValue::from_static(
1004 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1005 )
1006 }),
1007 );
1008 h.insert(
1009 "X-Permitted-Cross-Domain-Policies",
1010 HeaderValue::from_static("none"),
1011 );
1012 h.insert(
1013 "Permissions-Policy",
1014 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1015 );
1016 h.insert(
1017 "Cross-Origin-Opener-Policy",
1018 HeaderValue::from_static("same-origin"),
1019 );
1020 h.insert(
1021 "Cross-Origin-Resource-Policy",
1022 HeaderValue::from_static("same-origin"),
1023 );
1024 if state.tls_enabled {
1025 h.insert(
1026 "Strict-Transport-Security",
1027 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1028 );
1029 }
1030 resp
1031}
1032
1033async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1034 let ip = req
1035 .extensions()
1036 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1037 .map(|c| c.0.ip())
1038 .or_else(|| {
1039 if state.trust_proxy {
1040 req.headers()
1041 .get("X-Forwarded-For")
1042 .and_then(|v| v.to_str().ok())
1043 .and_then(|s| s.split(',').next())
1044 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1045 } else {
1046 None
1047 }
1048 })
1049 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1050
1051 if !state.rate_limiter.is_allowed(ip) {
1052 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1053 path = %req.uri().path(), "Rate limit exceeded");
1054 return (
1055 StatusCode::TOO_MANY_REQUESTS,
1056 [(header::RETRY_AFTER, "60")],
1057 "429 Too Many Requests\n",
1058 )
1059 .into_response();
1060 }
1061 next.run(req).await
1062}
1063
1064async fn splash(
1065 State(state): State<AppState>,
1066 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1067) -> impl IntoResponse {
1068 let lan_ip = if state.server_mode {
1069 primary_lan_ip()
1070 } else {
1071 None
1072 };
1073 let port = state
1074 .base_config
1075 .web
1076 .bind_address
1077 .rsplit(':')
1078 .next()
1079 .and_then(|p| p.parse::<u16>().ok())
1080 .unwrap_or(4317);
1081 let template = SplashTemplate {
1082 csp_nonce,
1083 server_mode: state.server_mode,
1084 lan_ip,
1085 port,
1086 version: env!("CARGO_PKG_VERSION"),
1087 };
1088 Html(
1089 template
1090 .render()
1091 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1092 )
1093}
1094
1095async fn index(
1096 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1097 Query(query): Query<IndexQuery>,
1098) -> impl IntoResponse {
1099 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1100 let policy = query
1101 .mixed_line_policy
1102 .unwrap_or_else(|| "code_only".to_string());
1103 let behavior = query
1104 .binary_file_behavior
1105 .unwrap_or_else(|| "skip".to_string());
1106 let cfg = ScanConfig {
1107 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1108 path: query.path.unwrap_or_default(),
1109 include_globs: query.include_globs.unwrap_or_default(),
1110 exclude_globs: query.exclude_globs.unwrap_or_default(),
1111 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1112 mixed_line_policy: policy,
1113 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1114 != Some("off"),
1115 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1116 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1117 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1118 != Some("disabled"),
1119 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1120 binary_file_behavior: behavior,
1121 output_dir: query.output_dir.unwrap_or_default(),
1122 report_title: query.report_title.unwrap_or_default(),
1123 generate_html: query.generate_html.as_deref() != Some("off"),
1124 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1125 };
1126 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1127 } else {
1128 "{}".to_string()
1129 };
1130
1131 let git_repo = query.git_repo.unwrap_or_default();
1132 let git_ref = query.git_ref.unwrap_or_default();
1133
1134 let git_label = make_git_label(&git_repo, &git_ref);
1135 let git_output_dir = if git_label.is_empty() {
1136 String::new()
1137 } else {
1138 desktop_dir().join(&git_label).display().to_string()
1139 };
1140 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1141 let git_output_dir_json =
1142 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1143
1144 let template = IndexTemplate {
1145 version: env!("CARGO_PKG_VERSION"),
1146 prefill_json,
1147 csp_nonce,
1148 git_repo,
1149 git_ref,
1150 git_label_json,
1151 git_output_dir_json,
1152 };
1153
1154 Html(
1155 template
1156 .render()
1157 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1158 )
1159}
1160
1161async fn scan_setup_handler(
1162 State(state): State<AppState>,
1163 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1164) -> impl IntoResponse {
1165 let recent_scans_json = {
1166 let arr: Vec<serde_json::Value> = {
1167 let reg = state.registry.lock().await;
1168 reg.entries
1169 .iter()
1170 .rev()
1171 .take(6)
1172 .map(|e| {
1173 let run_dir = e
1174 .html_path
1175 .as_ref()
1176 .or(e.json_path.as_ref())
1177 .and_then(|p| p.parent().map(PathBuf::from));
1178 let config_val: Option<serde_json::Value> = run_dir
1179 .and_then(|d| find_scan_config_in_dir(&d))
1180 .and_then(|p| fs::read_to_string(&p).ok())
1181 .and_then(|s| serde_json::from_str(&s).ok());
1182 serde_json::json!({
1183 "project_label": e.project_label,
1184 "timestamp": fmt_la_time(e.timestamp_utc),
1185 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1186 "config": config_val,
1187 })
1188 })
1189 .collect()
1190 };
1191 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1192 };
1193
1194 let template = ScanSetupTemplate {
1195 version: env!("CARGO_PKG_VERSION"),
1196 recent_scans_json,
1197 csp_nonce,
1198 };
1199 Html(
1200 template
1201 .render()
1202 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1203 )
1204}
1205
1206async fn healthz() -> &'static str {
1207 "ok"
1208}
1209
1210async fn api_docs_handler(
1211 State(state): State<AppState>,
1212 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1213) -> impl IntoResponse {
1214 let has_api_key = !state.api_keys.is_empty();
1215 Html(
1216 ApiDocsTemplate {
1217 has_api_key,
1218 csp_nonce,
1219 version: env!("CARGO_PKG_VERSION"),
1220 }
1221 .render()
1222 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1223 )
1224}
1225
1226async fn chart_js_handler() -> impl IntoResponse {
1227 (
1228 [(
1229 header::CONTENT_TYPE,
1230 "application/javascript; charset=utf-8",
1231 )],
1232 CHART_JS,
1233 )
1234}
1235
1236#[derive(Debug, Deserialize)]
1237struct AnalyzeForm {
1238 path: String,
1239 git_repo: Option<String>,
1240 git_ref: Option<String>,
1241 mixed_line_policy: Option<MixedLinePolicy>,
1242 python_docstrings_as_comments: Option<String>,
1243 generated_file_detection: Option<String>,
1244 minified_file_detection: Option<String>,
1245 vendor_directory_detection: Option<String>,
1246 include_lockfiles: Option<String>,
1247 binary_file_behavior: Option<BinaryFileBehavior>,
1248 output_dir: Option<String>,
1249 report_title: Option<String>,
1250 report_header_footer: Option<String>,
1251 generate_html: Option<String>,
1252 generate_pdf: Option<String>,
1253 include_globs: Option<String>,
1254 exclude_globs: Option<String>,
1255 submodule_breakdown: Option<String>,
1256 coverage_file: Option<String>,
1257}
1258
1259#[allow(clippy::struct_excessive_bools)]
1260#[derive(Debug, Serialize, Deserialize, Clone)]
1261struct ScanConfig {
1262 oxide_sloc_version: String,
1263 path: String,
1264 include_globs: String,
1265 exclude_globs: String,
1266 submodule_breakdown: bool,
1267 mixed_line_policy: String,
1268 python_docstrings_as_comments: bool,
1269 generated_file_detection: bool,
1270 minified_file_detection: bool,
1271 vendor_directory_detection: bool,
1272 include_lockfiles: bool,
1273 binary_file_behavior: String,
1274 output_dir: String,
1275 report_title: String,
1276 generate_html: bool,
1277 generate_pdf: bool,
1278}
1279
1280#[derive(Debug, Deserialize, Default)]
1281struct IndexQuery {
1282 path: Option<String>,
1283 include_globs: Option<String>,
1284 exclude_globs: Option<String>,
1285 submodule_breakdown: Option<String>,
1286 mixed_line_policy: Option<String>,
1287 python_docstrings_as_comments: Option<String>,
1288 generated_file_detection: Option<String>,
1289 minified_file_detection: Option<String>,
1290 vendor_directory_detection: Option<String>,
1291 include_lockfiles: Option<String>,
1292 binary_file_behavior: Option<String>,
1293 output_dir: Option<String>,
1294 report_title: Option<String>,
1295 generate_html: Option<String>,
1296 generate_pdf: Option<String>,
1297 prefilled: Option<String>,
1298 git_repo: Option<String>,
1299 git_ref: Option<String>,
1300}
1301
1302#[derive(Debug, Deserialize)]
1303struct PreviewQuery {
1304 path: Option<String>,
1305 include_globs: Option<String>,
1306 exclude_globs: Option<String>,
1307}
1308
1309#[cfg(feature = "native-dialog")]
1310#[derive(Debug, Deserialize)]
1311struct PickDirectoryQuery {
1312 kind: Option<String>,
1313 current: Option<String>,
1314}
1315
1316#[cfg(not(feature = "native-dialog"))]
1317#[derive(Debug, Deserialize)]
1318struct PickDirectoryQuery {}
1319
1320#[derive(Debug, Deserialize, Default)]
1321struct ArtifactQuery {
1322 download: Option<String>,
1323}
1324
1325#[cfg(feature = "native-dialog")]
1326#[derive(Debug, Serialize)]
1327struct PickDirectoryResponse {
1328 selected_path: Option<String>,
1329 cancelled: bool,
1330}
1331
1332#[cfg(feature = "native-dialog")]
1333async fn pick_directory_handler(
1334 State(state): State<AppState>,
1335 Query(query): Query<PickDirectoryQuery>,
1336) -> Response {
1337 if state.server_mode {
1338 return StatusCode::NOT_FOUND.into_response();
1339 }
1340
1341 let is_coverage = query.kind.as_deref() == Some("coverage");
1342 let title = match query.kind.as_deref() {
1343 Some("output") => "Select output directory",
1344 Some("reports") => "Select folder containing saved reports",
1345 Some("coverage") => "Select LCOV coverage file",
1346 _ => "Select project directory",
1347 }
1348 .to_owned();
1349 let current = query.current.clone();
1350
1351 let picked = tokio::task::spawn_blocking(move || {
1352 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1355 let fg_tid = win_dialog_focus::attach_to_foreground();
1356 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1357 win_dialog_focus::flash_dialog_when_ready(title.clone());
1358
1359 let mut dialog = rfd::FileDialog::new().set_title(&title);
1360 if let Some(current) = current.as_deref() {
1361 let resolved = resolve_input_path(current);
1362 let seed = if resolved.is_dir() {
1363 Some(resolved)
1364 } else {
1365 resolved.parent().map(Path::to_path_buf)
1366 };
1367 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1368 dialog = dialog.set_directory(seed_dir);
1369 }
1370 }
1371 let result = if is_coverage {
1372 dialog
1373 .add_filter(
1374 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1375 &["info", "lcov", "xml"],
1376 )
1377 .pick_file()
1378 } else {
1379 dialog.pick_folder()
1380 };
1381
1382 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1383 win_dialog_focus::detach_from_foreground(fg_tid);
1384
1385 result
1386 })
1387 .await
1388 .unwrap_or(None);
1389
1390 Json(PickDirectoryResponse {
1391 selected_path: picked.as_ref().map(|p| display_path(p)),
1392 cancelled: picked.is_none(),
1393 })
1394 .into_response()
1395}
1396
1397#[cfg(not(feature = "native-dialog"))]
1398async fn pick_directory_handler(
1399 State(_state): State<AppState>,
1400 Query(_query): Query<PickDirectoryQuery>,
1401) -> Response {
1402 StatusCode::NOT_FOUND.into_response()
1403}
1404
1405#[cfg(feature = "native-dialog")]
1406async fn pick_file_handler(State(state): State<AppState>) -> Response {
1407 if state.server_mode {
1408 return StatusCode::NOT_FOUND.into_response();
1409 }
1410 let picked = tokio::task::spawn_blocking(|| {
1411 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1412 let fg_tid = win_dialog_focus::attach_to_foreground();
1413 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1414 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1415
1416 let result = rfd::FileDialog::new()
1417 .set_title("Select HTML report")
1418 .add_filter("HTML report", &["html"])
1419 .pick_file();
1420
1421 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1422 win_dialog_focus::detach_from_foreground(fg_tid);
1423
1424 result
1425 })
1426 .await
1427 .unwrap_or(None);
1428 Json(PickDirectoryResponse {
1429 selected_path: picked.as_ref().map(|p| display_path(p)),
1430 cancelled: picked.is_none(),
1431 })
1432 .into_response()
1433}
1434
1435#[cfg(not(feature = "native-dialog"))]
1436async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1437 StatusCode::NOT_FOUND.into_response()
1438}
1439
1440#[derive(Deserialize)]
1441struct LocateReportForm {
1442 file_path: String,
1443}
1444
1445fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1447 let html = ErrorTemplate {
1448 message: message.into(),
1449 last_report_url: Some("/view-reports".to_string()),
1450 last_report_label: Some("View Reports".to_string()),
1451 csp_nonce: csp_nonce.to_owned(),
1452 }
1453 .render()
1454 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1455 Html(html).into_response()
1456}
1457
1458fn registry_entry_from_run(
1460 run: &AnalysisRun,
1461 json_path: PathBuf,
1462 html_path: PathBuf,
1463) -> RegistryEntry {
1464 let project_label = run.input_roots.first().map_or_else(
1465 || "Unknown Project".to_string(),
1466 |r| sanitize_project_label(r),
1467 );
1468 RegistryEntry {
1469 run_id: run.tool.run_id.clone(),
1470 timestamp_utc: run.tool.timestamp_utc,
1471 project_label,
1472 input_roots: run.input_roots.clone(),
1473 json_path: Some(json_path),
1474 html_path: Some(html_path),
1475 pdf_path: None,
1476 summary: ScanSummarySnapshot {
1477 files_analyzed: run.summary_totals.files_analyzed,
1478 files_skipped: run.summary_totals.files_skipped,
1479 total_physical_lines: run.summary_totals.total_physical_lines,
1480 code_lines: run.summary_totals.code_lines,
1481 comment_lines: run.summary_totals.comment_lines,
1482 blank_lines: run.summary_totals.blank_lines,
1483 functions: run.summary_totals.functions,
1484 classes: run.summary_totals.classes,
1485 variables: run.summary_totals.variables,
1486 imports: run.summary_totals.imports,
1487 test_count: run.summary_totals.test_count,
1488 },
1489 csv_path: None,
1490 xlsx_path: None,
1491 git_branch: None,
1492 git_commit: None,
1493 git_author: None,
1494 git_tags: None,
1495 git_nearest_tag: None,
1496 git_commit_date: None,
1497 }
1498}
1499
1500pub(crate) async fn register_artifacts_in_registry(
1503 state: &AppState,
1504 label: &str,
1505 run: &AnalysisRun,
1506 artifacts: &RunArtifacts,
1507) {
1508 let Some(json_path) = artifacts.json_path.clone() else {
1509 return;
1510 };
1511 let Some(html_path) = artifacts.html_path.clone() else {
1512 return;
1513 };
1514 let mut entry = registry_entry_from_run(run, json_path, html_path);
1515 entry.project_label = label.to_owned();
1516 let mut reg = state.registry.lock().await;
1517 reg.add_entry(entry);
1518 let _ = reg.save(&state.registry_path);
1519}
1520
1521#[allow(clippy::result_large_err)]
1526fn validate_locate_request(
1527 state: &AppState,
1528 file_path: &str,
1529 csp_nonce: &str,
1530) -> Result<(PathBuf, PathBuf), Response> {
1531 let file_ext = Path::new(file_path)
1532 .extension()
1533 .and_then(|e| e.to_str())
1534 .unwrap_or("")
1535 .to_ascii_lowercase();
1536 if file_ext != "html" {
1537 return Err(locate_report_error(
1538 "Only .html report files can be located via this form.",
1539 csp_nonce,
1540 ));
1541 }
1542 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1543 Ok(p) => strip_unc_prefix(p),
1544 Err(_) => {
1545 return Err(locate_report_error(
1546 "Report file not found or path is invalid.",
1547 csp_nonce,
1548 ));
1549 }
1550 };
1551 if state.server_mode {
1552 let output_root = resolve_output_root(None);
1553 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1554 if !html_path.starts_with(&canonical_root) {
1555 return Err(locate_report_error(
1556 "Report file must be within the configured output directory.",
1557 csp_nonce,
1558 ));
1559 }
1560 }
1561 let parent = match html_path.parent() {
1562 Some(p) => p.to_path_buf(),
1563 None => {
1564 return Err(locate_report_error(
1565 "Report file has no parent directory.",
1566 csp_nonce,
1567 ));
1568 }
1569 };
1570 Ok((html_path, parent))
1571}
1572
1573fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1575 if server_mode {
1576 String::new()
1577 } else {
1578 format!("\n\nFile: {}", path.display())
1579 }
1580}
1581
1582async fn locate_report_handler(
1583 State(state): State<AppState>,
1584 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1585 Form(form): Form<LocateReportForm>,
1586) -> impl IntoResponse {
1587 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1588 Ok(v) => v,
1589 Err(resp) => return resp,
1590 };
1591
1592 let json_candidate = parent.join("result.json");
1593 let mut reg = state.registry.lock().await;
1594 let entry_idx = reg.entries.iter().position(|e| {
1596 let json_match = e
1597 .json_path
1598 .as_ref()
1599 .and_then(|p| p.parent())
1600 .is_some_and(|p| p == parent);
1601 let html_match = e
1602 .html_path
1603 .as_ref()
1604 .and_then(|p| p.parent())
1605 .is_some_and(|p| p == parent);
1606 json_match || html_match
1607 });
1608 if let Some(idx) = entry_idx {
1609 reg.entries[idx].html_path = Some(html_path);
1610 let _ = reg.save(&state.registry_path);
1611 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1612 }
1613 if json_candidate.exists() {
1615 match read_json(&json_candidate) {
1616 Ok(run) => {
1617 let entry = registry_entry_from_run(&run, json_candidate, html_path);
1618 reg.add_entry(entry);
1619 let _ = reg.save(&state.registry_path);
1620 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1621 }
1622 Err(e) => {
1623 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1624 let err_detail = if state.server_mode {
1625 String::new()
1626 } else {
1627 format!("\n\nError: {e}")
1628 };
1629 return locate_report_error(
1630 format!(
1631 "Could not link this report.\n\nA 'result.json' was found but could not \
1632 be parsed — it may have been saved by an older version of OxideSLOC. \
1633 Re-running the analysis will create a fresh, compatible \
1634 record.{file_hint}{err_detail}"
1635 ),
1636 &csp_nonce,
1637 );
1638 }
1639 }
1640 }
1641 drop(reg);
1642 let file_hint = locate_path_hint(state.server_mode, &html_path);
1643 locate_report_error(
1644 format!(
1645 "Could not link this report.\n\nNo matching scan record was found, and no \
1646 'result.json' was found in the same folder.{file_hint}"
1647 ),
1648 &csp_nonce,
1649 )
1650}
1651
1652fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1654 fs::read_dir(dir)
1655 .ok()?
1656 .flatten()
1657 .map(|e| e.path())
1658 .find(|p| {
1659 p.is_file()
1660 && p.file_stem()
1661 .and_then(|n| n.to_str())
1662 .is_some_and(|n| n.starts_with("result"))
1663 && p.extension()
1664 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1665 })
1666}
1667
1668#[derive(Deserialize)]
1669struct LocateReportsDirForm {
1670 folder_path: String,
1671}
1672
1673#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
1675 State(state): State<AppState>,
1676 Form(form): Form<LocateReportsDirForm>,
1677) -> impl IntoResponse {
1678 if state.server_mode {
1679 return StatusCode::NOT_FOUND.into_response();
1680 }
1681 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1682 Ok(p) => strip_unc_prefix(p),
1683 Err(_) => {
1684 return axum::response::Redirect::to(
1685 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1686 )
1687 .into_response();
1688 }
1689 };
1690 if !folder.is_dir() {
1691 return axum::response::Redirect::to(
1692 "/view-reports?error=Selected+path+is+not+a+directory.",
1693 )
1694 .into_response();
1695 }
1696
1697 let candidates = collect_result_json_candidates(&folder);
1698
1699 if candidates.is_empty() {
1700 return axum::response::Redirect::to(
1701 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1702 )
1703 .into_response();
1704 }
1705
1706 let mut linked_count: usize = 0;
1707 let mut reg = state.registry.lock().await;
1708 for json_path in candidates {
1709 let Some(parent) = json_path.parent().map(PathBuf::from) else {
1710 continue;
1711 };
1712 if is_dir_already_registered(®, &parent) {
1713 continue;
1714 }
1715 let Some(entry) = build_registry_entry_from_json(json_path) else {
1716 continue;
1717 };
1718 reg.add_entry(entry);
1719 linked_count += 1;
1720 }
1721 let _ = reg.save(&state.registry_path);
1722 drop(reg);
1723
1724 if linked_count == 0 {
1725 return axum::response::Redirect::to(
1726 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
1727 )
1728 .into_response();
1729 }
1730 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
1731}
1732
1733#[derive(Deserialize)]
1734struct RelocateScanForm {
1735 run_id: String,
1736 folder_path: String,
1737 redirect_url: String,
1738}
1739
1740async fn relocate_scan_handler(
1741 State(state): State<AppState>,
1742 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1743 Form(form): Form<RelocateScanForm>,
1744) -> impl IntoResponse {
1745 if state.server_mode {
1746 return StatusCode::NOT_FOUND.into_response();
1747 }
1748
1749 let run_id = form.run_id.trim().to_string();
1750 let redirect_url = form.redirect_url.trim().to_string();
1751
1752 let run_exists = {
1753 let reg = state.registry.lock().await;
1754 reg.find_by_run_id(&run_id).is_some()
1755 };
1756 if !run_exists {
1757 let html = ErrorTemplate {
1758 message: format!("Run ID '{run_id}' not found in registry."),
1759 last_report_url: Some("/compare-scans".to_string()),
1760 last_report_label: Some("Compare Scans".to_string()),
1761 csp_nonce: csp_nonce.clone(),
1762 }
1763 .render()
1764 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1765 return Html(html).into_response();
1766 }
1767
1768 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
1769 Ok(p) => strip_unc_prefix(p),
1770 Err(_) => {
1771 return missing_scan_relocate_response(
1772 "Folder not found or path is invalid.",
1773 &run_id,
1774 form.folder_path.trim(),
1775 &redirect_url,
1776 false,
1777 &csp_nonce,
1778 );
1779 }
1780 };
1781 if !folder.is_dir() {
1782 return missing_scan_relocate_response(
1783 "Selected path is not a directory.",
1784 &run_id,
1785 &folder.display().to_string(),
1786 &redirect_url,
1787 false,
1788 &csp_nonce,
1789 );
1790 }
1791
1792 let json_candidates = find_result_files_by_ext(&folder, "json");
1793 if json_candidates.is_empty() {
1794 return missing_scan_relocate_response(
1795 &format!(
1796 "No result JSON files found in the selected folder.\nSearched: {}",
1797 folder.display()
1798 ),
1799 &run_id,
1800 &folder.display().to_string(),
1801 &redirect_url,
1802 false,
1803 &csp_nonce,
1804 );
1805 }
1806
1807 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
1808 return missing_scan_relocate_response(
1809 &format!(
1810 "No matching scan found in the selected folder.\n\
1811 The JSON files present do not contain run ID: {run_id}\n\
1812 Searched: {}",
1813 folder.display()
1814 ),
1815 &run_id,
1816 &folder.display().to_string(),
1817 &redirect_url,
1818 false,
1819 &csp_nonce,
1820 );
1821 };
1822
1823 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
1824 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
1825 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
1826
1827 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
1828 redirect_url
1829 } else {
1830 "/compare-scans".to_string()
1831 };
1832 axum::response::Redirect::to(&safe_redirect).into_response()
1833}
1834
1835fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
1836 fs::read_dir(folder)
1837 .ok()
1838 .into_iter()
1839 .flatten()
1840 .flatten()
1841 .map(|e| e.path())
1842 .filter(|p| {
1843 p.is_file()
1844 && p.file_stem()
1845 .and_then(|n| n.to_str())
1846 .is_some_and(|n| n.starts_with("result"))
1847 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
1848 })
1849 .collect()
1850}
1851
1852fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
1853 candidates
1854 .iter()
1855 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
1856 .cloned()
1857}
1858
1859async fn update_run_file_paths(
1860 state: &AppState,
1861 run_id: &str,
1862 json_path: PathBuf,
1863 html_path: Option<PathBuf>,
1864 pdf_path: Option<PathBuf>,
1865) {
1866 let mut reg = state.registry.lock().await;
1867 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
1868 entry.json_path = Some(json_path);
1869 if let Some(hp) = html_path {
1870 entry.html_path = Some(hp);
1871 }
1872 if let Some(pp) = pdf_path {
1873 entry.pdf_path = Some(pp);
1874 }
1875 }
1876 let _ = reg.save(&state.registry_path);
1877}
1878
1879fn missing_scan_relocate_response(
1880 message: &str,
1881 run_id: &str,
1882 folder_hint: &str,
1883 redirect_url: &str,
1884 server_mode: bool,
1885 csp_nonce: &str,
1886) -> axum::response::Response {
1887 let html = RelocateScanTemplate {
1888 message: message.to_string(),
1889 run_id: run_id.to_string(),
1890 folder_hint: folder_hint.to_string(),
1891 redirect_url: redirect_url.to_string(),
1892 server_mode,
1893 csp_nonce: csp_nonce.to_owned(),
1894 }
1895 .render()
1896 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1897 (StatusCode::NOT_FOUND, Html(html)).into_response()
1898}
1899
1900fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
1904 let mut candidates = Vec::new();
1905 if let Some(j) = find_result_json_in_dir(folder) {
1906 candidates.push(j);
1907 }
1908 if let Ok(dir_entries) = fs::read_dir(folder) {
1909 for entry in dir_entries.flatten() {
1910 let sub = entry.path();
1911 if sub.is_dir() {
1912 if let Some(j) = find_result_json_in_dir(&sub) {
1913 candidates.push(j);
1914 }
1915 }
1916 }
1917 }
1918 candidates
1919}
1920
1921fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
1922 reg.entries.iter().any(|e| {
1923 let dir_match = e
1924 .json_path
1925 .as_ref()
1926 .and_then(|p| p.parent())
1927 .is_some_and(|p| p == parent)
1928 || e.html_path
1929 .as_ref()
1930 .and_then(|p| p.parent())
1931 .is_some_and(|p| p == parent);
1932 dir_match
1933 && (e.json_path.as_ref().is_some_and(|p| p.exists())
1934 || e.html_path.as_ref().is_some_and(|p| p.exists()))
1935 })
1936}
1937
1938fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
1939 let parent = json_path.parent()?.to_path_buf();
1940 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1941 rd.flatten()
1942 .map(|e| e.path())
1943 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
1944 });
1945 let run = read_json(&json_path).ok()?;
1946 let project_label = run.input_roots.first().map_or_else(
1947 || "Unknown Project".to_string(),
1948 |r| sanitize_project_label(r),
1949 );
1950 Some(RegistryEntry {
1951 run_id: run.tool.run_id.clone(),
1952 timestamp_utc: run.tool.timestamp_utc,
1953 project_label,
1954 input_roots: run.input_roots.clone(),
1955 json_path: Some(json_path),
1956 html_path,
1957 pdf_path: None,
1958 csv_path: None,
1959 xlsx_path: None,
1960 summary: ScanSummarySnapshot {
1961 files_analyzed: run.summary_totals.files_analyzed,
1962 files_skipped: run.summary_totals.files_skipped,
1963 total_physical_lines: run.summary_totals.total_physical_lines,
1964 code_lines: run.summary_totals.code_lines,
1965 comment_lines: run.summary_totals.comment_lines,
1966 blank_lines: run.summary_totals.blank_lines,
1967 functions: run.summary_totals.functions,
1968 classes: run.summary_totals.classes,
1969 variables: run.summary_totals.variables,
1970 imports: run.summary_totals.imports,
1971 test_count: run.summary_totals.test_count,
1972 },
1973 git_branch: run.git_branch.clone(),
1974 git_commit: run.git_commit_short.clone(),
1975 git_author: run.git_commit_author.clone(),
1976 git_tags: run.git_tags.clone(),
1977 git_nearest_tag: run.git_nearest_tag.clone(),
1978 git_commit_date: run.git_commit_date,
1979 })
1980}
1981
1982fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
1985 let mut linked = 0usize;
1986 for json_path in collect_result_json_candidates(folder) {
1987 let Some(parent) = json_path.parent().map(PathBuf::from) else {
1988 continue;
1989 };
1990 if is_dir_already_registered(reg, &parent) {
1991 continue;
1992 }
1993 let Some(entry) = build_registry_entry_from_json(json_path) else {
1994 continue;
1995 };
1996 reg.add_entry(entry);
1997 linked += 1;
1998 }
1999 linked
2000}
2001
2002async fn auto_scan_watched_dirs(state: &AppState) {
2004 let dirs: Vec<PathBuf> = {
2005 let wd = state.watched_dirs.lock().await;
2006 wd.dirs.clone()
2007 };
2008 if dirs.is_empty() {
2009 return;
2010 }
2011 let mut reg = state.registry.lock().await;
2012 let mut total = 0usize;
2013 for dir in &dirs {
2014 if dir.is_dir() {
2015 total += scan_folder_into_registry(dir, &mut reg);
2016 }
2017 }
2018 if total > 0 {
2019 let _ = reg.save(&state.registry_path);
2020 }
2021}
2022
2023#[derive(Deserialize)]
2026struct WatchedDirForm {
2027 folder_path: String,
2028 #[serde(default = "default_redirect")]
2029 redirect_to: String,
2030}
2031
2032fn default_redirect() -> String {
2033 "/view-reports".to_string()
2034}
2035
2036#[derive(Deserialize)]
2037struct WatchedDirRefreshForm {
2038 #[serde(default = "default_redirect")]
2039 redirect_to: String,
2040}
2041
2042fn safe_redirect(dest: &str) -> &str {
2046 if dest.starts_with('/') {
2047 dest
2048 } else {
2049 "/"
2050 }
2051}
2052
2053async fn add_watched_dir_handler(
2056 State(state): State<AppState>,
2057 Form(form): Form<WatchedDirForm>,
2058) -> impl IntoResponse {
2059 if state.server_mode {
2060 return StatusCode::NOT_FOUND.into_response();
2061 }
2062 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2063 strip_unc_prefix(p)
2064 } else {
2065 let dest = format!(
2066 "{}?error=Folder+not+found+or+path+is+invalid.",
2067 safe_redirect(&form.redirect_to)
2068 );
2069 return axum::response::Redirect::to(&dest).into_response();
2070 };
2071 if !folder.is_dir() {
2072 let dest = format!(
2073 "{}?error=Selected+path+is+not+a+directory.",
2074 safe_redirect(&form.redirect_to)
2075 );
2076 return axum::response::Redirect::to(&dest).into_response();
2077 }
2078
2079 {
2081 let mut wd = state.watched_dirs.lock().await;
2082 wd.add(folder.clone());
2083 let _ = wd.save(&state.watched_dirs_path);
2084 }
2085
2086 let linked = {
2088 let mut reg = state.registry.lock().await;
2089 let n = scan_folder_into_registry(&folder, &mut reg);
2090 if n > 0 {
2091 let _ = reg.save(&state.registry_path);
2092 }
2093 n
2094 };
2095
2096 let dest = if linked > 0 {
2097 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2098 } else {
2099 format!(
2100 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2101 safe_redirect(&form.redirect_to)
2102 )
2103 };
2104 axum::response::Redirect::to(&dest).into_response()
2105}
2106
2107async fn remove_watched_dir_handler(
2108 State(state): State<AppState>,
2109 Form(form): Form<WatchedDirForm>,
2110) -> impl IntoResponse {
2111 if state.server_mode {
2112 return StatusCode::NOT_FOUND.into_response();
2113 }
2114 let folder = PathBuf::from(&form.folder_path);
2115 {
2116 let mut wd = state.watched_dirs.lock().await;
2117 wd.remove(&folder);
2118 let _ = wd.save(&state.watched_dirs_path);
2119 }
2120 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2121}
2122
2123async fn refresh_watched_dirs_handler(
2124 State(state): State<AppState>,
2125 Form(form): Form<WatchedDirRefreshForm>,
2126) -> impl IntoResponse {
2127 if state.server_mode {
2128 return StatusCode::NOT_FOUND.into_response();
2129 }
2130 let dirs: Vec<PathBuf> = {
2131 let wd = state.watched_dirs.lock().await;
2132 wd.dirs.clone()
2133 };
2134 let mut total = 0usize;
2135 {
2136 let mut reg = state.registry.lock().await;
2137 for dir in &dirs {
2138 if dir.is_dir() {
2139 total += scan_folder_into_registry(dir, &mut reg);
2140 }
2141 }
2142 if total > 0 {
2143 let _ = reg.save(&state.registry_path);
2144 }
2145 }
2146 let dest = if total > 0 {
2147 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2148 } else {
2149 safe_redirect(&form.redirect_to).to_owned()
2150 };
2151 axum::response::Redirect::to(&dest).into_response()
2152}
2153
2154#[derive(Debug, Deserialize)]
2155struct OpenPathQuery {
2156 path: Option<String>,
2157}
2158
2159async fn open_path_handler(
2160 State(state): State<AppState>,
2161 Query(query): Query<OpenPathQuery>,
2162) -> impl IntoResponse {
2163 if state.server_mode {
2164 return StatusCode::NOT_FOUND.into_response();
2165 }
2166 let raw = match query.path.as_deref() {
2167 Some(p) if !p.is_empty() => p,
2168 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2169 };
2170
2171 let target = match fs::canonicalize(raw) {
2175 Ok(canonical) if canonical.is_file() => match canonical.parent() {
2176 Some(p) => p.to_path_buf(),
2177 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2178 },
2179 Ok(canonical) if canonical.is_dir() => canonical,
2180 Ok(_) => {
2181 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2182 }
2183 Err(_) => {
2184 let mut ancestor = std::path::Path::new(raw);
2186 loop {
2187 match ancestor.parent() {
2188 Some(p) => {
2189 ancestor = p;
2190 if ancestor.is_dir() {
2191 break;
2192 }
2193 }
2194 None => {
2195 return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2196 .into_response();
2197 }
2198 }
2199 }
2200 ancestor.to_path_buf()
2201 }
2202 };
2203
2204 #[cfg(target_os = "windows")]
2205 {
2206 let ps_cmd = "Add-Type -TypeDefinition \
2210 'using System;using System.Runtime.InteropServices;\
2211 public class WF{\
2212 [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2213 [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2214 }'; \
2215 $p=$env:SLOC_OPEN_PATH; \
2216 $sh=New-Object -ComObject Shell.Application; \
2217 $sh.Open($p); \
2218 Start-Sleep -Milliseconds 600; \
2219 foreach($w in $sh.Windows()){ \
2220 try{ \
2221 if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2222 [System.IO.Path]::GetFullPath($p)){ \
2223 [WF]::ShowWindow($w.HWND,3); \
2224 [WF]::SetForegroundWindow($w.HWND); \
2225 break \
2226 } \
2227 }catch{} \
2228 }";
2229 let _ = std::process::Command::new("powershell")
2230 .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2231 .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2232 .stdout(Stdio::null())
2233 .stderr(Stdio::null())
2234 .spawn();
2235 }
2236 #[cfg(target_os = "macos")]
2237 let _ = std::process::Command::new("open")
2238 .arg(&target)
2239 .stdout(Stdio::null())
2240 .stderr(Stdio::null())
2241 .spawn();
2242 #[cfg(target_os = "linux")]
2243 let _ = std::process::Command::new("xdg-open")
2244 .arg(&target)
2245 .stdout(Stdio::null())
2246 .stderr(Stdio::null())
2247 .spawn();
2248
2249 (StatusCode::OK, "ok").into_response()
2250}
2251
2252async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2253 let (content_type, bytes): (&'static str, &'static [u8]) =
2254 match (folder.as_str(), file.as_str()) {
2255 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2256 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2257 ("icons", "c.png") => ("image/png", IMG_ICON_C),
2258 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2259 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2260 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2261 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2262 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2263 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2264 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2265 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2266 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2267 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2268 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2269 ("icons", "r.png") => ("image/png", IMG_ICON_R),
2270 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2271 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2272 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2273 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2274 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2275 _ => return StatusCode::NOT_FOUND.into_response(),
2276 };
2277 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2278}
2279
2280async fn preview_handler(
2281 State(state): State<AppState>,
2282 Query(query): Query<PreviewQuery>,
2283) -> impl IntoResponse {
2284 let raw_path = query
2285 .path
2286 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2287 let resolved = resolve_input_path(&raw_path);
2288
2289 if state.server_mode {
2290 let config = &state.base_config;
2291 if config.discovery.allowed_scan_roots.is_empty() {
2292 return Html(
2293 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2294 );
2295 }
2296 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2297 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2298 fs::canonicalize(root)
2299 .ok()
2300 .is_some_and(|r| canonical.starts_with(&r))
2301 });
2302 if !allowed {
2303 return Html(
2304 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2305 );
2306 }
2307 }
2308
2309 let include_patterns = split_patterns(query.include_globs.as_deref());
2310 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2311
2312 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2313 Ok(html) => Html(html),
2314 Err(err) => Html(format!(
2315 r#"<div class="preview-error">Preview failed: {}</div>"#,
2316 escape_html(&err.to_string())
2317 )),
2318 }
2319}
2320
2321#[derive(Debug, Deserialize, Default)]
2322struct SuggestCoverageQuery {
2323 path: Option<String>,
2324}
2325
2326#[derive(Serialize)]
2327struct SuggestCoverageResponse {
2328 found: Option<String>,
2329 tool: Option<&'static str>,
2330 hint: Option<&'static str>,
2331}
2332
2333async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2334 const CANDIDATES: &[&str] = &[
2335 "coverage/lcov.info",
2337 "lcov.info",
2338 "target/llvm-cov/lcov.info",
2339 "target/coverage/lcov.info",
2340 "target/debug/coverage/lcov.info",
2341 "coverage/coverage.lcov",
2342 "build/coverage/lcov.info",
2343 "reports/lcov.info",
2344 "coverage.xml",
2346 "coverage/coverage.xml",
2347 "target/site/cobertura/coverage.xml",
2348 "build/reports/coverage/coverage.xml",
2349 "target/site/jacoco/jacoco.xml",
2351 "build/reports/jacoco/test/jacocoTestReport.xml",
2352 "build/reports/jacoco/jacocoTestReport.xml",
2353 "build/jacoco/jacoco.xml",
2354 ];
2355 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2356 let found = CANDIDATES
2357 .iter()
2358 .map(|rel| root.join(rel))
2359 .find(|p| p.is_file())
2360 .map(|p| display_path(&p));
2361
2362 let (tool, hint) = detect_coverage_tool(&root);
2363 Json(SuggestCoverageResponse { found, tool, hint })
2364}
2365
2366fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2369 if root.join("Cargo.toml").is_file() {
2370 return (
2371 Some("cargo-llvm-cov"),
2372 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2373 );
2374 }
2375 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2376 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2377 }
2378 if root.join("pom.xml").is_file() {
2379 return (Some("jacoco"), Some("mvn test jacoco:report"));
2380 }
2381 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2382 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2383 }
2384 (None, None)
2385}
2386
2387#[allow(clippy::result_large_err)]
2389fn validate_server_scan_path(
2390 config: &sloc_config::AppConfig,
2391 resolved_path: &Path,
2392 csp_nonce: &str,
2393) -> Result<(), Response> {
2394 if config.discovery.allowed_scan_roots.is_empty() {
2395 let template = ErrorTemplate {
2396 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2397 Set allowed_scan_roots in the server config to permit scanning."
2398 .to_string(),
2399 last_report_url: None,
2400 last_report_label: None,
2401 csp_nonce: csp_nonce.to_owned(),
2402 };
2403 return Err((
2404 StatusCode::FORBIDDEN,
2405 Html(
2406 template
2407 .render()
2408 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2409 ),
2410 )
2411 .into_response());
2412 }
2413 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2414 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2415 fs::canonicalize(root)
2416 .ok()
2417 .is_some_and(|r| canonical.starts_with(&r))
2418 });
2419 if !allowed {
2420 tracing::warn!(event = "path_rejected", path = %canonical.display(),
2421 "Scan path not in allowed_scan_roots");
2422 let template = ErrorTemplate {
2423 message: "The requested path is not within an allowed scan directory.".to_string(),
2424 last_report_url: None,
2425 last_report_label: None,
2426 csp_nonce: csp_nonce.to_owned(),
2427 };
2428 return Err((
2429 StatusCode::FORBIDDEN,
2430 Html(
2431 template
2432 .render()
2433 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2434 ),
2435 )
2436 .into_response());
2437 }
2438 Ok(())
2439}
2440
2441fn apply_output_dir_exclusions(
2443 config: &mut sloc_config::AppConfig,
2444 project_path: &str,
2445 raw_output_dir: &str,
2446) {
2447 let project_root = resolve_input_path(project_path);
2448 let raw_out = raw_output_dir.trim();
2449 let resolved_out = if raw_out.is_empty() {
2450 project_root.join("sloc")
2451 } else if Path::new(raw_out).is_absolute() {
2452 PathBuf::from(raw_out)
2453 } else {
2454 workspace_root().join(raw_out)
2455 };
2456 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2457 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2458 let dir = first.to_string();
2459 if !config.discovery.excluded_directories.contains(&dir) {
2460 config.discovery.excluded_directories.push(dir);
2461 }
2462 }
2463 }
2464 if !config
2465 .discovery
2466 .excluded_directories
2467 .iter()
2468 .any(|d| d == "sloc")
2469 {
2470 config
2471 .discovery
2472 .excluded_directories
2473 .push("sloc".to_string());
2474 }
2475}
2476
2477const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2479 ScanSummarySnapshot {
2480 files_analyzed: run.summary_totals.files_analyzed,
2481 files_skipped: run.summary_totals.files_skipped,
2482 total_physical_lines: run.summary_totals.total_physical_lines,
2483 code_lines: run.summary_totals.code_lines,
2484 comment_lines: run.summary_totals.comment_lines,
2485 blank_lines: run.summary_totals.blank_lines,
2486 functions: run.summary_totals.functions,
2487 classes: run.summary_totals.classes,
2488 variables: run.summary_totals.variables,
2489 imports: run.summary_totals.imports,
2490 test_count: run.summary_totals.test_count,
2491 }
2492}
2493
2494pub(crate) fn build_run_registry_entry(
2496 run: &AnalysisRun,
2497 run_id: &str,
2498 project_label: &str,
2499 artifacts: &RunArtifacts,
2500) -> RegistryEntry {
2501 RegistryEntry {
2502 run_id: run_id.to_owned(),
2503 timestamp_utc: run.tool.timestamp_utc,
2504 project_label: project_label.to_owned(),
2505 input_roots: run.input_roots.clone(),
2506 json_path: artifacts.json_path.clone(),
2507 html_path: artifacts.html_path.clone(),
2508 pdf_path: artifacts.pdf_path.clone(),
2509 csv_path: artifacts.csv_path.clone(),
2510 xlsx_path: artifacts.xlsx_path.clone(),
2511 summary: summary_snapshot_from_run(run),
2512 git_branch: run.git_branch.clone(),
2513 git_commit: run.git_commit_short.clone(),
2514 git_author: run.git_commit_author.clone(),
2515 git_tags: run.git_tags.clone(),
2516 git_nearest_tag: run.git_nearest_tag.clone(),
2517 git_commit_date: run.git_commit_date.clone(),
2518 }
2519}
2520
2521fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2523 if let Some(policy) = form.mixed_line_policy {
2524 config.analysis.mixed_line_policy = policy;
2525 }
2526 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2527 config.analysis.generated_file_detection =
2528 form.generated_file_detection.as_deref() != Some("disabled");
2529 config.analysis.minified_file_detection =
2530 form.minified_file_detection.as_deref() != Some("disabled");
2531 config.analysis.vendor_directory_detection =
2532 form.vendor_directory_detection.as_deref() != Some("disabled");
2533 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2534 if let Some(binary_behavior) = form.binary_file_behavior {
2535 config.analysis.binary_file_behavior = binary_behavior;
2536 }
2537 if let Some(report_title) = form.report_title.as_deref() {
2538 let trimmed = report_title.trim();
2539 if !trimmed.is_empty() {
2540 config.reporting.report_title = trimmed.to_string();
2541 }
2542 }
2543 if let Some(hf) = form.report_header_footer.as_deref() {
2544 let trimmed = hf.trim();
2545 config.reporting.report_header_footer = if trimmed.is_empty() {
2546 None
2547 } else {
2548 Some(trimmed.to_string())
2549 };
2550 }
2551 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2552 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2553 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2554 if let Some(cov) = &form.coverage_file {
2555 let trimmed = cov.trim();
2556 if !trimmed.is_empty() {
2557 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2558 }
2559 }
2560}
2561
2562fn spawn_pdf_background(
2566 pending_pdf: PendingPdf,
2567 run_id: String,
2568 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
2569) {
2570 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2571 tokio::spawn(async move {
2572 let result = tokio::task::spawn_blocking(move || {
2573 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2574 if cleanup_src {
2575 let _ = fs::remove_file(&pdf_src);
2576 }
2577 r
2578 })
2579 .await;
2580 let failed = match result {
2581 Ok(Ok(())) => false,
2582 Ok(Err(err)) => {
2583 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
2584 true
2585 }
2586 Err(err) => {
2587 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
2588 true
2589 }
2590 };
2591 if failed {
2592 let mut map = artifacts.lock().await;
2593 if let Some(entry) = map.get_mut(&run_id) {
2594 entry.pdf_path = None;
2595 }
2596 }
2597 });
2598 }
2599}
2600
2601fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2603 cmp.file_deltas
2604 .iter()
2605 .map(|f| match f.status {
2606 FileChangeStatus::Added => f.current_code,
2607 FileChangeStatus::Modified => f.code_delta.max(0),
2608 _ => 0,
2609 })
2610 .sum()
2611}
2612
2613fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2615 cmp.file_deltas
2616 .iter()
2617 .map(|f| match f.status {
2618 FileChangeStatus::Removed => f.baseline_code,
2619 FileChangeStatus::Modified => (-f.code_delta).max(0),
2620 _ => 0,
2621 })
2622 .sum()
2623}
2624
2625fn build_submodule_row(
2627 s: &sloc_core::SubmoduleSummary,
2628 run: &AnalysisRun,
2629 run_id: &str,
2630 run_dir: &Path,
2631 generate_html: bool,
2632) -> SubmoduleRow {
2633 let safe = sanitize_project_label(&s.name);
2634 let artifact_key = format!("sub_{safe}");
2635 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2636 let parent_path = run
2637 .input_roots
2638 .first()
2639 .map_or("", std::string::String::as_str);
2640 let sub_run = build_sub_run(run, s, parent_path);
2641 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2642 let path = run_dir.join(format!("{artifact_key}.html"));
2643 if fs::write(&path, sub_html.as_bytes()).is_ok() {
2644 Some(format!("/runs/{artifact_key}/{run_id}"))
2645 } else {
2646 None
2647 }
2648 })
2649 } else {
2650 None
2651 };
2652 SubmoduleRow {
2653 name: s.name.clone(),
2654 relative_path: s.relative_path.clone(),
2655 files_analyzed: s.files_analyzed,
2656 code_lines: s.code_lines,
2657 comment_lines: s.comment_lines,
2658 blank_lines: s.blank_lines,
2659 total_physical_lines: s.total_physical_lines,
2660 html_url,
2661 }
2662}
2663
2664#[allow(clippy::similar_names)]
2667#[allow(clippy::significant_drop_tightening)] async fn analyze_handler(
2669 State(state): State<AppState>,
2670 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2671 Form(form): Form<AnalyzeForm>,
2672) -> impl IntoResponse {
2673 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2674 let template = ErrorTemplate {
2675 message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2676 .to_string(),
2677 last_report_url: None,
2678 last_report_label: None,
2679 csp_nonce: csp_nonce.clone(),
2680 };
2681 return (
2682 StatusCode::SERVICE_UNAVAILABLE,
2683 Html(
2684 template
2685 .render()
2686 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2687 ),
2688 )
2689 .into_response();
2690 };
2691
2692 let mut config = state.base_config.clone();
2693
2694 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2695 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2696 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2697
2698 if !is_git_mode {
2699 let resolved_path = resolve_input_path(&form.path);
2700 if state.server_mode {
2701 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
2702 return resp;
2703 }
2704 }
2705 config.discovery.root_paths = vec![resolved_path];
2706 }
2707
2708 apply_form_to_config(&mut config, &form);
2709 apply_output_dir_exclusions(
2710 &mut config,
2711 &form.path,
2712 form.output_dir.as_deref().unwrap_or(""),
2713 );
2714
2715 let wait_id = uuid::Uuid::new_v4().to_string();
2717 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
2718
2719 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
2721 let task_cancel = Arc::clone(&cancel_token);
2722
2723 {
2726 let mut runs = state.async_runs.lock().await;
2727 runs.insert(
2728 wait_id.clone(),
2729 AsyncRunState::Running {
2730 started_at: std::time::Instant::now(),
2731 cancel_token,
2732 },
2733 );
2734 }
2735
2736 let task = AnalysisTask {
2737 sem_permit,
2738 state: state.clone(),
2739 wait_id: wait_id.clone(),
2740 config,
2741 cancel: task_cancel,
2742 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
2743 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
2744 generate_html: form.generate_html.is_some(),
2745 generate_pdf: form.generate_pdf.is_some(),
2746 project_path: form.path.clone(),
2747 output_dir: form.output_dir.clone(),
2748 clones_dir: state.git_clones_dir.clone(),
2749 };
2750
2751 tokio::spawn(run_analysis_task(task));
2752
2753 let template = ScanWaitTemplate {
2754 version: env!("CARGO_PKG_VERSION"),
2755 wait_id_json,
2756 project_path: form.path.clone(),
2757 csp_nonce,
2758 };
2759 let html = template
2760 .render()
2761 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
2762 let mut response = Html(html).into_response();
2763 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
2764 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
2765 response.headers_mut().insert(name, val);
2766 }
2767 }
2768 response
2769}
2770
2771struct AnalysisTask {
2772 sem_permit: tokio::sync::OwnedSemaphorePermit,
2773 state: AppState,
2774 wait_id: String,
2775 config: AppConfig,
2776 cancel: Arc<std::sync::atomic::AtomicBool>,
2777 git_repo: Option<String>,
2778 git_ref: Option<String>,
2779 generate_html: bool,
2780 generate_pdf: bool,
2781 project_path: String,
2782 output_dir: Option<String>,
2783 clones_dir: PathBuf,
2784}
2785
2786#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
2788 let _permit = task.sem_permit;
2789
2790 let cancel_sb = Arc::clone(&task.cancel);
2791 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
2792 let clones_dir_sb = task.clones_dir;
2793 let config_sb = task.config;
2794 let analysis_result = tokio::task::spawn_blocking(move || {
2795 run_analysis_blocking(config_sb, git_repo_sb, git_ref_sb, clones_dir_sb, cancel_sb)
2796 })
2797 .await
2798 .map_err(|err| anyhow::anyhow!(err.to_string()))
2799 .and_then(|result| result);
2800
2801 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2803 let mut runs = task.state.async_runs.lock().await;
2804 if matches!(
2806 runs.get(&task.wait_id),
2807 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
2808 ) {
2809 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
2810 }
2811 drop(runs);
2812 return;
2813 }
2814
2815 let (run, report_html) = match analysis_result {
2816 Ok(v) => v,
2817 Err(err) => {
2818 if err.to_string().contains("analysis cancelled") {
2820 let mut runs = task.state.async_runs.lock().await;
2821 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
2822 drop(runs);
2823 return;
2824 }
2825 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
2826 let mut runs = task.state.async_runs.lock().await;
2827 runs.insert(
2828 task.wait_id.clone(),
2829 AsyncRunState::Failed {
2830 message: "Analysis failed. Check that the path exists and is readable."
2831 .to_string(),
2832 },
2833 );
2834 drop(runs);
2835 return;
2836 }
2837 };
2838
2839 let run_id = run.tool.run_id.clone();
2840 tracing::info!(event = "scan_complete", run_id = %run_id,
2841 path = %task.project_path, files = run.summary_totals.files_analyzed,
2842 "Analysis finished");
2843
2844 let prev_entry: Option<RegistryEntry> = {
2845 let reg = task.state.registry.lock().await;
2846 reg.entries_for_roots(&run.input_roots)
2847 .into_iter()
2848 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2849 .cloned()
2850 };
2851
2852 let scan_delta = prev_entry.as_ref().and_then(|prev| {
2853 prev.json_path
2854 .as_ref()
2855 .and_then(|p| read_json(p).ok())
2856 .map(|prev_run| compute_delta(&prev_run, &run))
2857 });
2858 let prev_scan_count: usize = {
2859 let reg = task.state.registry.lock().await;
2860 reg.entries_for_roots(&run.input_roots)
2861 .iter()
2862 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2863 .count()
2864 };
2865
2866 let output_root = resolve_output_root(task.output_dir.as_deref());
2867 let project_label = derive_project_label(
2868 task.git_repo.as_deref(),
2869 task.git_ref.as_deref(),
2870 &task.project_path,
2871 );
2872 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
2873 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
2874
2875 let result_context = RunResultContext {
2876 prev_entry: prev_entry.clone(),
2877 prev_scan_count,
2878 project_path: task.project_path.clone(),
2879 };
2880
2881 let artifact_result = persist_run_artifacts(
2882 &run,
2883 &report_html,
2884 &run_dir,
2885 true,
2886 task.generate_html,
2887 task.generate_pdf,
2888 &run.effective_configuration.reporting.report_title,
2889 &file_stem,
2890 result_context,
2891 );
2892
2893 let (artifacts, pending_pdf) = match artifact_result {
2894 Ok(v) => v,
2895 Err(err) => {
2896 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
2897 let mut runs = task.state.async_runs.lock().await;
2898 runs.insert(
2899 task.wait_id.clone(),
2900 AsyncRunState::Failed {
2901 message: "Failed to save report artifacts. Check available disk space."
2902 .to_string(),
2903 },
2904 );
2905 drop(runs);
2906 return;
2907 }
2908 };
2909
2910 {
2911 let mut map = task.state.artifacts.lock().await;
2912 map.insert(run_id.clone(), artifacts.clone());
2913 }
2914
2915 {
2916 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
2917 let mut reg = task.state.registry.lock().await;
2918 reg.add_entry(entry);
2919 let _ = reg.save(&task.state.registry_path);
2920 }
2921
2922 if let Some(ref cfg_path) = artifacts.scan_config_path {
2923 save_scan_config_json(
2924 cfg_path,
2925 &run,
2926 &task.project_path,
2927 task.output_dir.as_deref(),
2928 task.generate_html,
2929 task.generate_pdf,
2930 );
2931 }
2932
2933 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
2934
2935 let mut runs = task.state.async_runs.lock().await;
2937 runs.insert(
2938 task.wait_id.clone(),
2939 AsyncRunState::Complete {
2940 run_id: run_id.clone(),
2941 },
2942 );
2943 drop(runs);
2944
2945 let _ = scan_delta;
2946}
2947
2948fn save_scan_config_json(
2949 cfg_path: &std::path::Path,
2950 run: &sloc_core::AnalysisRun,
2951 project_path: &str,
2952 output_dir: Option<&str>,
2953 generate_html: bool,
2954 generate_pdf: bool,
2955) {
2956 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
2957 .ok()
2958 .and_then(|v| v.as_str().map(String::from))
2959 .unwrap_or_else(|| "code_only".to_string());
2960 let behavior_str =
2961 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
2962 .ok()
2963 .and_then(|v| v.as_str().map(String::from))
2964 .unwrap_or_else(|| "skip".to_string());
2965 let scan_cfg = ScanConfig {
2966 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2967 path: project_path.to_string(),
2968 include_globs: run
2969 .effective_configuration
2970 .discovery
2971 .include_globs
2972 .join("\n"),
2973 exclude_globs: run
2974 .effective_configuration
2975 .discovery
2976 .exclude_globs
2977 .join("\n"),
2978 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
2979 mixed_line_policy: policy_str,
2980 python_docstrings_as_comments: run
2981 .effective_configuration
2982 .analysis
2983 .python_docstrings_as_comments,
2984 generated_file_detection: run
2985 .effective_configuration
2986 .analysis
2987 .generated_file_detection,
2988 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
2989 vendor_directory_detection: run
2990 .effective_configuration
2991 .analysis
2992 .vendor_directory_detection,
2993 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
2994 binary_file_behavior: behavior_str,
2995 output_dir: output_dir.unwrap_or("").to_string(),
2996 report_title: run.effective_configuration.reporting.report_title.clone(),
2997 generate_html,
2998 generate_pdf,
2999 };
3000 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3001 let _ = std::fs::write(cfg_path, json);
3002 }
3003}
3004
3005#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
3007 mut config: AppConfig,
3008 git_repo: Option<String>,
3009 git_ref: Option<String>,
3010 clones_dir: PathBuf,
3011 cancel: Arc<std::sync::atomic::AtomicBool>,
3012) -> Result<(sloc_core::AnalysisRun, String)> {
3013 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3014 let dest = git_clone_dest(&repo, &clones_dir);
3015 sloc_git::clone_or_fetch(&repo, &dest)?;
3016 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3017 sloc_git::create_worktree(&dest, &refname, &wt)?;
3018 config.discovery.root_paths = vec![wt.clone()];
3019 let run = analyze(&config, "serve", Some(&cancel));
3020 let _ = sloc_git::destroy_worktree(&dest, &wt);
3021 let mut run = run?;
3022 if run.git_branch.is_none() {
3023 run.git_branch = Some(refname);
3024 }
3025 let html = render_html(&run)?;
3026 return Ok((run, html));
3027 }
3028 let run = analyze(&config, "serve", Some(&cancel))?;
3029 let html = render_html(&run)?;
3030 Ok((run, html))
3031}
3032
3033fn derive_project_label(
3034 git_repo: Option<&str>,
3035 git_ref: Option<&str>,
3036 fallback_path: &str,
3037) -> String {
3038 match (
3039 git_repo.filter(|s| !s.is_empty()),
3040 git_ref.filter(|s| !s.is_empty()),
3041 ) {
3042 (Some(repo), Some(refname)) => {
3043 let repo_name = repo
3044 .trim_end_matches('/')
3045 .trim_end_matches(".git")
3046 .rsplit('/')
3047 .next()
3048 .unwrap_or("repo");
3049 sanitize_project_label(&format!("{repo_name}_{refname}"))
3050 }
3051 _ => sanitize_project_label(fallback_path),
3052 }
3053}
3054
3055fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3056 let commit = commit_short.unwrap_or("").trim();
3057 if commit.is_empty() {
3058 project_label.to_string()
3059 } else {
3060 format!("{project_label}_{commit}")
3061 }
3062}
3063
3064#[derive(Serialize)]
3067#[serde(tag = "state", rename_all = "snake_case")]
3068enum AsyncRunStatusResponse {
3069 Running { elapsed_secs: u64 },
3070 Complete { run_id: String },
3071 Failed { message: String },
3072 Cancelled,
3073}
3074
3075async fn async_run_status_handler(
3076 State(state): State<AppState>,
3077 AxumPath(wait_id): AxumPath<String>,
3078) -> Response {
3079 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3081 return error::bad_request("invalid wait_id");
3082 }
3083 let run_state = {
3084 let runs = state.async_runs.lock().await;
3085 runs.get(&wait_id).cloned()
3086 };
3087 match run_state {
3088 None => error::not_found("run not found"),
3089 Some(AsyncRunState::Running { started_at, .. }) => {
3090 if started_at.elapsed() > std::time::Duration::from_hours(2) {
3092 let mut runs = state.async_runs.lock().await;
3093 runs.insert(
3094 wait_id,
3095 AsyncRunState::Failed {
3096 message: "Analysis timed out after 2 hours.".to_string(),
3097 },
3098 );
3099 drop(runs);
3100 return Json(AsyncRunStatusResponse::Failed {
3101 message: "Analysis timed out after 2 hours.".to_string(),
3102 })
3103 .into_response();
3104 }
3105 Json(AsyncRunStatusResponse::Running {
3106 elapsed_secs: started_at.elapsed().as_secs(),
3107 })
3108 .into_response()
3109 }
3110 Some(AsyncRunState::Complete { run_id }) => {
3111 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3112 }
3113 Some(AsyncRunState::Failed { message }) => {
3114 Json(AsyncRunStatusResponse::Failed { message }).into_response()
3115 }
3116 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3117 }
3118}
3119
3120async fn cancel_run_handler(
3121 State(state): State<AppState>,
3122 AxumPath(wait_id): AxumPath<String>,
3123) -> Response {
3124 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3125 return error::bad_request("invalid wait_id");
3126 }
3127 let mut runs = state.async_runs.lock().await;
3128 let resp = match runs.get(&wait_id) {
3129 Some(AsyncRunState::Running { cancel_token, .. }) => {
3130 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3131 runs.insert(wait_id, AsyncRunState::Cancelled);
3132 StatusCode::OK.into_response()
3133 }
3134 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3135 _ => error::not_found("run not found"),
3136 };
3137 drop(runs);
3138 resp
3139}
3140
3141async fn async_run_result_handler(
3142 State(state): State<AppState>,
3143 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3144 AxumPath(run_id): AxumPath<String>,
3145) -> Response {
3146 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3147 return StatusCode::BAD_REQUEST.into_response();
3148 }
3149
3150 let artifacts = {
3151 let map = state.artifacts.lock().await;
3152 map.get(&run_id).cloned()
3153 };
3154 let artifacts = if let Some(a) = artifacts {
3155 a
3156 } else {
3157 let reg = state.registry.lock().await;
3158 if let Some(entry) = reg.find_by_run_id(&run_id) {
3159 recover_artifacts_from_registry(entry)
3160 } else {
3161 let html = ErrorTemplate {
3162 message: format!(
3163 "Report not found. Run ID {} is not in the scan history.",
3164 &run_id[..run_id.len().min(8)]
3165 ),
3166 last_report_url: Some("/view-reports".to_string()),
3167 last_report_label: Some("View Reports".to_string()),
3168 csp_nonce: csp_nonce.clone(),
3169 }
3170 .render()
3171 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3172 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3173 }
3174 };
3175
3176 let json_path = if let Some(p) = &artifacts.json_path {
3177 p.clone()
3178 } else {
3179 let html = ErrorTemplate {
3180 message: "JSON result was not saved for this run.".to_string(),
3181 last_report_url: Some("/view-reports".to_string()),
3182 last_report_label: Some("View Reports".to_string()),
3183 csp_nonce: csp_nonce.clone(),
3184 }
3185 .render()
3186 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3187 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3188 };
3189
3190 let Ok(run) = read_json(&json_path) else {
3191 let folder_hint = json_path
3192 .parent()
3193 .map(|p| p.display().to_string())
3194 .unwrap_or_default();
3195 let redirect_url = format!("/runs/result/{run_id}");
3196 return missing_scan_relocate_response(
3197 &format!(
3198 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
3199 deleted. Browse to the folder containing your scan output to reconnect it.",
3200 json_path.display()
3201 ),
3202 &run_id,
3203 &folder_hint,
3204 &redirect_url,
3205 state.server_mode,
3206 &csp_nonce,
3207 );
3208 };
3209
3210 let confluence_configured = {
3211 let store = state.confluence.lock().await;
3212 store.is_configured()
3213 };
3214
3215 render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3216}
3217
3218#[allow(clippy::too_many_lines)]
3219#[allow(clippy::similar_names)] fn render_result_page(
3221 run: &AnalysisRun,
3222 artifacts: &RunArtifacts,
3223 run_id: &str,
3224 csp_nonce: &str,
3225 confluence_configured: bool,
3226) -> Response {
3227 let ctx = &artifacts.result_context;
3228 let prev_entry = &ctx.prev_entry;
3229 let prev_scan_count = ctx.prev_scan_count;
3230 let project_path = &ctx.project_path;
3231
3232 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3233 prev.json_path
3234 .as_ref()
3235 .and_then(|p| read_json(p).ok())
3236 .map(|prev_run| compute_delta(&prev_run, run))
3237 });
3238
3239 let files_analyzed = run.per_file_records.len() as u64;
3240 let files_skipped = run.skipped_file_records.len() as u64;
3241 let physical_lines = run
3242 .totals_by_language
3243 .iter()
3244 .map(|r| r.total_physical_lines)
3245 .sum::<u64>();
3246 let code_lines = run
3247 .totals_by_language
3248 .iter()
3249 .map(|r| r.code_lines)
3250 .sum::<u64>();
3251 let comment_lines = run
3252 .totals_by_language
3253 .iter()
3254 .map(|r| r.comment_lines)
3255 .sum::<u64>();
3256 let blank_lines = run
3257 .totals_by_language
3258 .iter()
3259 .map(|r| r.blank_lines)
3260 .sum::<u64>();
3261 let mixed_lines = run
3262 .totals_by_language
3263 .iter()
3264 .map(|r| r.mixed_lines_separate)
3265 .sum::<u64>();
3266 let functions = run
3267 .totals_by_language
3268 .iter()
3269 .map(|r| r.functions)
3270 .sum::<u64>();
3271 let classes = run
3272 .totals_by_language
3273 .iter()
3274 .map(|r| r.classes)
3275 .sum::<u64>();
3276 let variables = run
3277 .totals_by_language
3278 .iter()
3279 .map(|r| r.variables)
3280 .sum::<u64>();
3281 let imports = run
3282 .totals_by_language
3283 .iter()
3284 .map(|r| r.imports)
3285 .sum::<u64>();
3286
3287 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3288 let prev_fa = prev_sum.map(|s| s.files_analyzed);
3289 let prev_fs = prev_sum.map(|s| s.files_skipped);
3290 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3291 let prev_cl = prev_sum.map(|s| s.code_lines);
3292 let prev_cml = prev_sum.map(|s| s.comment_lines);
3293 let prev_bl = prev_sum.map(|s| s.blank_lines);
3294 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3295 let prev_fa_str = fmt_prev(prev_fa);
3296 let prev_fs_str = fmt_prev(prev_fs);
3297 let prev_pl_str = fmt_prev(prev_pl);
3298 let prev_cl_str = fmt_prev(prev_cl);
3299 let prev_cml_str = fmt_prev(prev_cml);
3300 let prev_bl_str = fmt_prev(prev_bl);
3301 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3302 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3303 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3304 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3305 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3306 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3307 let delta_fa_class = delta_fa_class.to_string();
3308 let delta_fs_class = delta_fs_class.to_string();
3309 let delta_pl_class = delta_pl_class.to_string();
3310 let delta_cl_class = delta_cl_class.to_string();
3311 let delta_cml_class = delta_cml_class.to_string();
3312 let delta_bl_class = delta_bl_class.to_string();
3313
3314 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3315 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3316 let (delta_lines_net_str, delta_lines_net_class) =
3317 match (delta_lines_added, delta_lines_removed) {
3318 (Some(a), Some(r)) => {
3319 let net = a - r;
3320 (fmt_delta(net), delta_class(net).to_string())
3321 }
3322 _ => ("—".to_string(), "na".to_string()),
3323 };
3324
3325 let run_dir = artifacts.output_dir.clone();
3326 let git_branch = run.git_branch.clone();
3327 let git_commit = run.git_commit_short.clone();
3328 let git_author = run.git_commit_author.clone();
3329
3330 let template = ResultTemplate {
3331 version: env!("CARGO_PKG_VERSION"),
3332 report_title: run.effective_configuration.reporting.report_title.clone(),
3333 project_path: project_path.clone(),
3334 output_dir: display_path(&artifacts.output_dir),
3335 run_id: run_id.to_owned(),
3336 files_analyzed,
3337 files_skipped,
3338 physical_lines,
3339 code_lines,
3340 comment_lines,
3341 blank_lines,
3342 mixed_lines,
3343 functions,
3344 classes,
3345 variables,
3346 imports,
3347 html_url: artifacts
3348 .html_path
3349 .as_ref()
3350 .map(|_| format!("/runs/html/{run_id}")),
3351 pdf_url: artifacts
3352 .pdf_path
3353 .as_ref()
3354 .map(|_| format!("/runs/pdf/{run_id}")),
3355 json_url: artifacts
3356 .json_path
3357 .as_ref()
3358 .map(|_| format!("/runs/json/{run_id}")),
3359 html_download_url: artifacts
3360 .html_path
3361 .as_ref()
3362 .map(|_| format!("/runs/html/{run_id}?download=1")),
3363 pdf_download_url: artifacts
3364 .pdf_path
3365 .as_ref()
3366 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3367 json_download_url: artifacts
3368 .json_path
3369 .as_ref()
3370 .map(|_| format!("/runs/json/{run_id}?download=1")),
3371 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3372 pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3373 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3374 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3375 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3376 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3377 prev_fa_str,
3378 prev_fs_str,
3379 prev_pl_str,
3380 prev_cl_str,
3381 prev_cml_str,
3382 prev_bl_str,
3383 delta_fa_str,
3384 delta_fa_class,
3385 delta_fs_str,
3386 delta_fs_class,
3387 delta_pl_str,
3388 delta_pl_class,
3389 delta_cl_str,
3390 delta_cl_class,
3391 delta_cml_str,
3392 delta_cml_class,
3393 delta_bl_str,
3394 delta_bl_class,
3395 delta_lines_added,
3396 delta_lines_removed,
3397 delta_lines_net_str,
3398 delta_lines_net_class,
3399 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3400 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3401 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3402 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3403 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3404 d.file_deltas
3405 .iter()
3406 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3407 .map(|f| {
3408 #[allow(clippy::cast_sign_loss)]
3409 let n = f.current_code as u64;
3410 n
3411 })
3412 .sum()
3413 }),
3414 git_branch,
3415 git_commit,
3416 git_author,
3417 current_scan_number: prev_scan_count + 1,
3418 prev_scan_count,
3419 submodule_rows: run
3420 .submodule_summaries
3421 .iter()
3422 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3423 .collect(),
3424 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3425 scan_config_url: format!("/runs/scan-config/{run_id}"),
3426 lang_chart_json: {
3427 let entries: Vec<String> = run
3428 .totals_by_language
3429 .iter()
3430 .take(12)
3431 .map(|l| {
3432 let name = l
3433 .language
3434 .display_name()
3435 .replace('\\', "\\\\")
3436 .replace('"', "\\\"");
3437 format!(
3438 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3439 name,
3440 l.code_lines,
3441 l.comment_lines,
3442 l.blank_lines,
3443 l.functions,
3444 l.classes,
3445 l.variables,
3446 l.imports,
3447 l.files,
3448 )
3449 })
3450 .collect();
3451 format!("[{}]", entries.join(","))
3452 },
3453 scatter_chart_json: {
3454 let entries: Vec<String> = run
3455 .totals_by_language
3456 .iter()
3457 .map(|l| {
3458 let name = l
3459 .language
3460 .display_name()
3461 .replace('\\', "\\\\")
3462 .replace('"', "\\\"");
3463 format!(
3464 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3465 name, l.files, l.code_lines, l.total_physical_lines,
3466 )
3467 })
3468 .collect();
3469 format!("[{}]", entries.join(","))
3470 },
3471 semantic_chart_json: {
3472 let entries: Vec<String> = run
3473 .totals_by_language
3474 .iter()
3475 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3476 .map(|l| {
3477 let name = l
3478 .language
3479 .display_name()
3480 .replace('\\', "\\\\")
3481 .replace('"', "\\\"");
3482 format!(
3483 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3484 name, l.functions, l.classes, l.variables, l.imports,
3485 )
3486 })
3487 .collect();
3488 format!("[{}]", entries.join(","))
3489 },
3490 submodule_chart_json: {
3491 let entries: Vec<String> = run
3492 .submodule_summaries
3493 .iter()
3494 .map(|s| {
3495 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3496 format!(
3497 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3498 name,
3499 s.code_lines,
3500 s.comment_lines,
3501 s.blank_lines,
3502 s.total_physical_lines,
3503 s.files_analyzed,
3504 )
3505 })
3506 .collect();
3507 format!("[{}]", entries.join(","))
3508 },
3509 has_submodule_data: !run.submodule_summaries.is_empty(),
3510 has_semantic_data: run
3511 .totals_by_language
3512 .iter()
3513 .any(|l| l.functions > 0 || l.classes > 0),
3514 csp_nonce: csp_nonce.to_owned(),
3515 confluence_configured,
3516 report_header_footer: run
3517 .effective_configuration
3518 .reporting
3519 .report_header_footer
3520 .clone(),
3521 };
3522
3523 Html(
3524 template
3525 .render()
3526 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3527 )
3528 .into_response()
3529}
3530
3531fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3532 let slug: String = report_title
3533 .chars()
3534 .map(|c| {
3535 if c.is_alphanumeric() || c == '-' {
3536 c.to_ascii_lowercase()
3537 } else {
3538 '_'
3539 }
3540 })
3541 .collect::<String>()
3542 .split('_')
3543 .filter(|s| !s.is_empty())
3544 .collect::<Vec<_>>()
3545 .join("_");
3546
3547 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3548
3549 if slug.is_empty() {
3550 format!("report_{short_id}.pdf")
3551 } else {
3552 format!("{slug}_{short_id}.pdf")
3553 }
3554}
3555
3556#[derive(Serialize)]
3557struct PdfStatusResponse {
3558 ready: bool,
3559}
3560
3561async fn pdf_status_handler(
3564 State(state): State<AppState>,
3565 AxumPath(run_id): AxumPath<String>,
3566) -> Response {
3567 let pdf_path = {
3568 let registry = state.artifacts.lock().await;
3569 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3570 };
3571 let pdf_path = if pdf_path.is_some() {
3572 pdf_path
3573 } else {
3574 let reg = state.registry.lock().await;
3575 reg.find_by_run_id(&run_id)
3576 .map(recover_artifacts_from_registry)
3577 .and_then(|a| a.pdf_path)
3578 };
3579 let ready = pdf_path.is_some_and(|p| p.exists());
3580 Json(PdfStatusResponse { ready }).into_response()
3581}
3582
3583fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3588 let Some(start) = html.find("nonce=\"") else {
3590 return html
3594 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3595 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3596 };
3597 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
3599 return html.to_owned();
3600 };
3601 let old_nonce = &html[value_start..value_start + end_offset];
3602 html.replace(
3603 &format!("nonce=\"{old_nonce}\""),
3604 &format!("nonce=\"{new_nonce}\""),
3605 )
3606}
3607
3608fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3609 match fs::read_to_string(path) {
3610 Ok(raw) => {
3611 let content = patch_html_nonce(&raw, csp_nonce);
3613 if wants_download {
3614 (
3615 [
3616 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3617 (
3618 header::CONTENT_DISPOSITION,
3619 "attachment; filename=report.html",
3620 ),
3621 ],
3622 content,
3623 )
3624 .into_response()
3625 } else {
3626 Html(content).into_response()
3627 }
3628 }
3629 Err(err) => {
3630 let filename = path.file_name().map_or_else(
3631 || "report.html".to_string(),
3632 |n| n.to_string_lossy().into_owned(),
3633 );
3634 let msg = format!(
3635 "HTML report '{filename}' could not be read.\n\n\
3636 Error: {err}\n\n\
3637 If you moved or renamed the output folder, the stored path is now stale. \
3638 Use 'Open HTML folder' from the results page to browse the output directory."
3639 );
3640 let html = ErrorTemplate {
3641 message: msg,
3642 last_report_url: Some("/view-reports".to_string()),
3643 last_report_label: Some("View Reports".to_string()),
3644 csp_nonce: csp_nonce.to_owned(),
3645 }
3646 .render()
3647 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3648 (StatusCode::NOT_FOUND, Html(html)).into_response()
3649 }
3650 }
3651}
3652
3653fn serve_pdf_artifact(
3655 path: &Path,
3656 report_title: &str,
3657 run_id: &str,
3658 wants_download: bool,
3659 csp_nonce: &str,
3660) -> Response {
3661 match fs::read(path) {
3662 Ok(bytes) => {
3663 let filename = build_pdf_filename(report_title, run_id);
3664 let disposition = if wants_download {
3665 format!("attachment; filename=\"{filename}\"")
3666 } else {
3667 format!("inline; filename=\"{filename}\"")
3668 };
3669 (
3670 [
3671 (header::CONTENT_TYPE, "application/pdf".to_string()),
3672 (header::CONTENT_DISPOSITION, disposition),
3673 ],
3674 bytes,
3675 )
3676 .into_response()
3677 }
3678 Err(err) => {
3679 let filename = path.file_name().map_or_else(
3680 || "report.pdf".to_string(),
3681 |n| n.to_string_lossy().into_owned(),
3682 );
3683 let msg = format!(
3684 "PDF report '{filename}' could not be read.\n\n\
3685 Error: {err}\n\n\
3686 If you moved or renamed the output folder, the stored path is now stale. \
3687 Use 'Open PDF folder' from the results page to browse the output directory."
3688 );
3689 let html = ErrorTemplate {
3690 message: msg,
3691 last_report_url: Some("/view-reports".to_string()),
3692 last_report_label: Some("View Reports".to_string()),
3693 csp_nonce: csp_nonce.to_owned(),
3694 }
3695 .render()
3696 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3697 (StatusCode::NOT_FOUND, Html(html)).into_response()
3698 }
3699 }
3700}
3701
3702fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3704 match fs::read(path) {
3705 Ok(bytes) => {
3706 if wants_download {
3707 (
3708 [
3709 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3710 (
3711 header::CONTENT_DISPOSITION,
3712 "attachment; filename=result.json",
3713 ),
3714 ],
3715 bytes,
3716 )
3717 .into_response()
3718 } else {
3719 (
3720 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3721 bytes,
3722 )
3723 .into_response()
3724 }
3725 }
3726 Err(err) => {
3727 let filename = path.file_name().map_or_else(
3728 || "result.json".to_string(),
3729 |n| n.to_string_lossy().into_owned(),
3730 );
3731 let msg = format!(
3732 "JSON result '{filename}' could not be read.\n\n\
3733 Error: {err}\n\n\
3734 If you moved or renamed the output folder, the stored path is now stale. \
3735 Use 'Open JSON folder' from the results page to browse the output directory."
3736 );
3737 let html = ErrorTemplate {
3738 message: msg,
3739 last_report_url: Some("/view-reports".to_string()),
3740 last_report_label: Some("View Reports".to_string()),
3741 csp_nonce: csp_nonce.to_owned(),
3742 }
3743 .render()
3744 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3745 (StatusCode::NOT_FOUND, Html(html)).into_response()
3746 }
3747 }
3748}
3749
3750fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3752 let output_dir = entry
3753 .html_path
3754 .as_ref()
3755 .or(entry.json_path.as_ref())
3756 .or(entry.pdf_path.as_ref())
3757 .or(entry.csv_path.as_ref())
3758 .or(entry.xlsx_path.as_ref())
3759 .and_then(|p| p.parent().map(PathBuf::from))
3760 .unwrap_or_default();
3761 let pdf_path = entry.pdf_path.clone().or_else(|| {
3764 let candidate = output_dir.join("report.pdf");
3765 candidate.exists().then_some(candidate)
3766 });
3767 let csv_path = entry.csv_path.clone().or_else(|| {
3771 fs::read_dir(&output_dir).ok().and_then(|entries| {
3772 entries
3773 .filter_map(std::result::Result::ok)
3774 .find(|e| {
3775 let n = e.file_name();
3776 let n = n.to_string_lossy();
3777 n.starts_with("report_") && n.ends_with(".csv")
3778 })
3779 .map(|e| e.path())
3780 })
3781 });
3782 let xlsx_path = entry.xlsx_path.clone().or_else(|| {
3783 fs::read_dir(&output_dir).ok().and_then(|entries| {
3784 entries
3785 .filter_map(std::result::Result::ok)
3786 .find(|e| {
3787 let n = e.file_name();
3788 let n = n.to_string_lossy();
3789 n.starts_with("report_") && n.ends_with(".xlsx")
3790 })
3791 .map(|e| e.path())
3792 })
3793 });
3794 RunArtifacts {
3795 output_dir: output_dir.clone(),
3796 html_path: entry.html_path.clone(),
3797 pdf_path,
3798 json_path: entry.json_path.clone(),
3799 csv_path,
3800 xlsx_path,
3801 scan_config_path: find_scan_config_in_dir(&output_dir),
3802 report_title: entry.project_label.clone(),
3803 result_context: RunResultContext::default(),
3804 }
3805}
3806
3807async fn resolve_artifact_set(
3808 state: &AppState,
3809 run_id: &str,
3810 csp_nonce: &str,
3811) -> Result<RunArtifacts, Response> {
3812 let cached = state.artifacts.lock().await.get(run_id).cloned();
3813 if let Some(a) = cached {
3814 return Ok(a);
3815 }
3816 let reg = state.registry.lock().await;
3817 if let Some(entry) = reg.find_by_run_id(run_id) {
3818 return Ok(recover_artifacts_from_registry(entry));
3819 }
3820 drop(reg);
3821 let short_id = &run_id[..run_id.len().min(8)];
3822 let hint = if matches!(
3823 run_id,
3824 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
3825 ) {
3826 format!(
3827 " The URL format appears to be reversed — \
3828 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
3829 Use the View Reports page to navigate to your scan."
3830 )
3831 } else {
3832 " The report may have been deleted or the report directory moved. \
3833 Use View Reports to browse your scan history."
3834 .to_string()
3835 };
3836 let error_html = ErrorTemplate {
3837 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
3838 last_report_url: Some("/view-reports".to_string()),
3839 last_report_label: Some("View Reports".to_string()),
3840 csp_nonce: csp_nonce.to_owned(),
3841 }
3842 .render()
3843 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3844 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
3845}
3846
3847#[allow(clippy::too_many_lines)] async fn artifact_handler(
3849 State(state): State<AppState>,
3850 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3851 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
3852 Query(query): Query<ArtifactQuery>,
3853) -> Response {
3854 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
3855 Ok(a) => a,
3856 Err(r) => return r,
3857 };
3858
3859 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
3860
3861 match artifact.as_str() {
3862 "html" => {
3863 let Some(path) = artifact_set.html_path else {
3864 return StatusCode::NOT_FOUND.into_response();
3865 };
3866 serve_html_artifact(&path, wants_download, &csp_nonce)
3867 }
3868 "pdf" => {
3869 let Some(path) = artifact_set.pdf_path else {
3870 let msg = "PDF report was not generated for this run, or was not recorded in \
3871 the scan registry. Re-run the analysis with PDF output enabled."
3872 .to_string();
3873 let html = ErrorTemplate {
3874 message: msg,
3875 last_report_url: Some(format!("/runs/html/{run_id}")),
3876 last_report_label: Some("View HTML Report".to_string()),
3877 csp_nonce: csp_nonce.clone(),
3878 }
3879 .render()
3880 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
3881 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3882 };
3883 if !path.exists() {
3886 let html = format!(
3887 "<!doctype html><html lang=\"en\"><head>\
3888 <meta charset=utf-8>\
3889 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
3890 <meta http-equiv=\"refresh\" content=\"5\">\
3891 <title>OxideSLOC | Generating PDF\u{2026}</title>\
3892 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
3893 <style nonce=\"{csp_nonce}\">\
3894 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
3895 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
3896 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
3897 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
3898 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
3899 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
3900 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
3901 background:var(--bg);color:var(--text);}}\
3902 .top-nav{{position:sticky;top:0;z-index:30;\
3903 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
3904 border-bottom:1px solid rgba(255,255,255,0.12);\
3905 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
3906 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
3907 min-height:56px;display:flex;align-items:center;gap:14px;}}\
3908 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
3909 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
3910 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
3911 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
3912 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
3913 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
3914 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
3915 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
3916 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
3917 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
3918 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
3919 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
3920 justify-content:center;min-height:38px;border-radius:999px;\
3921 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
3922 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
3923 .theme-toggle .icon-sun{{display:none;}}\
3924 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
3925 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
3926 .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
3927 display:flex;align-items:center;justify-content:center;\
3928 min-height:calc(100vh - 56px);}}\
3929 .panel{{background:var(--surface);border:1px solid var(--line);\
3930 border-radius:var(--radius);box-shadow:var(--shadow);\
3931 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
3932 .spin-ring{{width:56px;height:56px;border-radius:50%;\
3933 border:5px solid var(--line);border-top-color:var(--oxide-2);\
3934 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
3935 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
3936 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
3937 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
3938 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
3939 min-height:42px;padding:0 20px;border-radius:14px;\
3940 border:1px solid var(--line-strong);text-decoration:none;\
3941 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
3942 .back-link:hover{{background:var(--line);}}\
3943 </style></head>\
3944 <body>\
3945 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
3946 <a class=\"brand\" href=\"/\">\
3947 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
3948 <div class=\"brand-copy\">\
3949 <div class=\"brand-title\">OxideSLOC</div>\
3950 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
3951 </div>\
3952 </a>\
3953 <div class=\"nav-right\">\
3954 <a class=\"nav-pill\" href=\"/\">Home</a>\
3955 <div class=\"nav-dropdown\">\
3956 <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>\
3957 <div class=\"nav-dropdown-menu\">\
3958 <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>\
3959 </div>\
3960 </div>\
3961 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
3962 <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>\
3963 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
3964 <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>\
3965 </button>\
3966 </div>\
3967 </div></div>\
3968 <div class=\"page\"><div class=\"panel\">\
3969 <div class=\"spin-ring\"></div>\
3970 <h1>Generating PDF\u{2026}</h1>\
3971 <p>The PDF is being rendered from the HTML report.<br>\
3972 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
3973 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
3974 </div></div>\
3975 <script nonce=\"{csp_nonce}\">\
3976 (function(){{\
3977 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
3978 if(s===\"dark\")b.classList.add(\"dark-theme\");\
3979 var t=document.getElementById(\"theme-toggle\");\
3980 if(t)t.addEventListener(\"click\",function(){{\
3981 var d=b.classList.toggle(\"dark-theme\");\
3982 localStorage.setItem(k,d?\"dark\":\"light\");\
3983 }});\
3984 }})();\
3985 </script>\
3986 </body></html>"
3987 );
3988 return Html(html).into_response();
3989 }
3990 serve_pdf_artifact(
3991 &path,
3992 &artifact_set.report_title,
3993 &run_id,
3994 wants_download,
3995 &csp_nonce,
3996 )
3997 }
3998 "json" => {
3999 let Some(path) = artifact_set.json_path else {
4000 let msg = "JSON result was not generated for this run, or was not recorded in \
4001 the scan registry. Re-run the analysis with JSON output enabled."
4002 .to_string();
4003 let html = ErrorTemplate {
4004 message: msg,
4005 last_report_url: Some("/view-reports".to_string()),
4006 last_report_label: Some("View Reports".to_string()),
4007 csp_nonce: csp_nonce.clone(),
4008 }
4009 .render()
4010 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4011 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4012 };
4013 serve_json_artifact(&path, wants_download, &csp_nonce)
4014 }
4015 "csv" => {
4016 let Some(path) = artifact_set.csv_path else {
4017 let msg = "CSV report was not generated for this run, or was not recorded in \
4018 the scan registry."
4019 .to_string();
4020 let html = ErrorTemplate {
4021 message: msg,
4022 last_report_url: Some(format!("/runs/html/{run_id}")),
4023 last_report_label: Some("View HTML Report".to_string()),
4024 csp_nonce: csp_nonce.clone(),
4025 }
4026 .render()
4027 .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
4028 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4029 };
4030 fs::read(&path).map_or_else(
4031 |_| StatusCode::NOT_FOUND.into_response(),
4032 |bytes| {
4033 let filename = path.file_name().map_or_else(
4034 || "report.csv".to_string(),
4035 |n| n.to_string_lossy().into_owned(),
4036 );
4037 (
4038 [
4039 (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
4040 (
4041 header::CONTENT_DISPOSITION,
4042 format!("attachment; filename=\"{filename}\""),
4043 ),
4044 ],
4045 bytes,
4046 )
4047 .into_response()
4048 },
4049 )
4050 }
4051 "xlsx" => {
4052 let Some(path) = artifact_set.xlsx_path else {
4053 let msg = "Excel report was not generated for this run, or was not recorded in \
4054 the scan registry."
4055 .to_string();
4056 let html = ErrorTemplate {
4057 message: msg,
4058 last_report_url: Some(format!("/runs/html/{run_id}")),
4059 last_report_label: Some("View HTML Report".to_string()),
4060 csp_nonce: csp_nonce.clone(),
4061 }
4062 .render()
4063 .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
4064 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4065 };
4066 fs::read(&path).map_or_else(
4067 |_| StatusCode::NOT_FOUND.into_response(),
4068 |bytes| {
4069 let filename = path.file_name().map_or_else(
4070 || "report.xlsx".to_string(),
4071 |n| n.to_string_lossy().into_owned(),
4072 );
4073 (
4074 [
4075 (
4076 header::CONTENT_TYPE,
4077 "application/vnd.openxmlformats-officedocument\
4078 .spreadsheetml.sheet"
4079 .to_string(),
4080 ),
4081 (
4082 header::CONTENT_DISPOSITION,
4083 format!("attachment; filename=\"{filename}\""),
4084 ),
4085 ],
4086 bytes,
4087 )
4088 .into_response()
4089 },
4090 )
4091 }
4092 "scan-config" => {
4093 let path = artifact_set
4094 .scan_config_path
4095 .as_deref()
4096 .map(std::path::Path::to_path_buf)
4097 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4098 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4099 fs::read(&path).map_or_else(
4100 |_| StatusCode::NOT_FOUND.into_response(),
4101 |bytes| {
4102 (
4103 [
4104 (
4105 header::CONTENT_TYPE,
4106 "application/json; charset=utf-8".to_string(),
4107 ),
4108 (
4109 header::CONTENT_DISPOSITION,
4110 "attachment; filename=\"scan-config.json\"".to_string(),
4111 ),
4112 ],
4113 bytes,
4114 )
4115 .into_response()
4116 },
4117 )
4118 }
4119 _ if artifact.starts_with("sub_") => {
4120 if artifact.len() > 128
4121 || !artifact
4122 .chars()
4123 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4124 {
4125 return StatusCode::BAD_REQUEST.into_response();
4126 }
4127 let filename = format!("{artifact}.html");
4128 let path = artifact_set.output_dir.join(&filename);
4129 if !path.exists() {
4130 let html = ErrorTemplate {
4131 message: format!(
4132 "Sub-report '{artifact}' was not found in the run directory.\n\
4133 Re-run the analysis with 'Detect and separate git submodules' \
4134 and HTML output enabled."
4135 ),
4136 last_report_url: Some("/view-reports".to_string()),
4137 last_report_label: Some("View Reports".to_string()),
4138 csp_nonce: csp_nonce.clone(),
4139 }
4140 .render()
4141 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4142 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4143 }
4144 serve_html_artifact(&path, wants_download, &csp_nonce)
4145 }
4146 _ => StatusCode::NOT_FOUND.into_response(),
4147 }
4148}
4149
4150struct SubmoduleLinkRow {
4153 name: String,
4154 url: String,
4155}
4156
4157struct HistoryEntryRow {
4158 run_id: String,
4159 run_id_short: String,
4160 timestamp: String,
4161 timestamp_utc_ms: i64,
4162 project_label: String,
4163 project_path: String,
4164 files_analyzed: u64,
4165 files_skipped: u64,
4166 code_lines: u64,
4167 comment_lines: u64,
4168 blank_lines: u64,
4169 git_branch: String,
4170 git_commit: String,
4171 has_html: bool,
4172 has_json: bool,
4173 has_pdf: bool,
4174 submodule_links: Vec<SubmoduleLinkRow>,
4175 submodule_names_csv: String,
4177}
4178
4179fn nth_weekday_of_month(
4181 year: i32,
4182 month: u32,
4183 weekday: chrono::Weekday,
4184 n: u32,
4185) -> chrono::NaiveDate {
4186 use chrono::Datelike;
4187 let mut count = 0u32;
4188 let mut day = 1u32;
4189 loop {
4190 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4191 if d.weekday() == weekday {
4192 count += 1;
4193 if count == n {
4194 return d;
4195 }
4196 }
4197 day += 1;
4198 }
4199}
4200
4201fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4205 use chrono::{Datelike, TimeZone};
4206 let year = dt.year();
4207 let dst_start = chrono::Utc.from_utc_datetime(
4208 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4209 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4210 );
4211 let dst_end = chrono::Utc.from_utc_datetime(
4212 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4213 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4214 );
4215 dt >= dst_start && dt < dst_end
4216}
4217
4218fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4219 if is_pacific_dst(dt) {
4220 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4221 .format("%Y-%m-%d %H:%M PDT")
4222 .to_string()
4223 } else {
4224 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4225 .format("%Y-%m-%d %H:%M PST")
4226 .to_string()
4227 }
4228}
4229
4230fn fmt_git_date(iso: &str) -> Option<String> {
4231 chrono::DateTime::parse_from_rfc3339(iso)
4232 .ok()
4233 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4234}
4235
4236fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4237 reg.entries
4238 .iter()
4239 .map(|e| {
4240 let submodule_links = {
4241 let mut links: Vec<SubmoduleLinkRow> = vec![];
4242 let sub_dir = e
4243 .html_path
4244 .as_ref()
4245 .and_then(|p| p.parent())
4246 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4247 if let Some(dir) = sub_dir {
4248 if let Ok(rd) = std::fs::read_dir(dir) {
4249 for entry_res in rd.flatten() {
4250 let fname = entry_res.file_name();
4251 let fname_str = fname.to_string_lossy();
4252 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4253 let stem = &fname_str[..fname_str.len() - 5];
4254 let display = stem[4..].replace('-', " ");
4255 links.push(SubmoduleLinkRow {
4256 name: display,
4257 url: format!("/runs/{stem}/{}", e.run_id),
4258 });
4259 }
4260 }
4261 }
4262 }
4263 links.sort_by(|a, b| a.name.cmp(&b.name));
4264 links
4265 };
4266 let submodule_names_csv = submodule_links
4267 .iter()
4268 .map(|l| l.name.as_str())
4269 .collect::<Vec<_>>()
4270 .join(",");
4271 HistoryEntryRow {
4272 run_id: e.run_id.clone(),
4273 run_id_short: e
4274 .run_id
4275 .split('-')
4276 .next_back()
4277 .unwrap_or(&e.run_id)
4278 .chars()
4279 .take(7)
4280 .collect(),
4281 timestamp: fmt_la_time(e.timestamp_utc),
4282 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4283 project_label: e.project_label.clone(),
4284 project_path: e
4285 .input_roots
4286 .first()
4287 .map(|s| sanitize_path_str(s))
4288 .unwrap_or_default(),
4289 files_analyzed: e.summary.files_analyzed,
4290 files_skipped: e.summary.files_skipped,
4291 code_lines: e.summary.code_lines,
4292 comment_lines: e.summary.comment_lines,
4293 blank_lines: e.summary.blank_lines,
4294 git_branch: e.git_branch.clone().unwrap_or_default(),
4295 git_commit: e.git_commit.clone().unwrap_or_default(),
4296 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4297 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4298 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4299 submodule_links,
4300 submodule_names_csv,
4301 }
4302 })
4303 .collect()
4304}
4305
4306#[derive(Deserialize, Default)]
4307struct HistoryQuery {
4308 linked: Option<String>,
4309 error: Option<String>,
4310}
4311
4312async fn history_handler(
4313 State(state): State<AppState>,
4314 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4315 Query(query): Query<HistoryQuery>,
4316) -> impl IntoResponse {
4317 auto_scan_watched_dirs(&state).await;
4319 let watched_dirs: Vec<String> = {
4320 let wd = state.watched_dirs.lock().await;
4321 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4322 };
4323 let mut entries = {
4324 let reg = state.registry.lock().await;
4325 make_history_rows(®)
4326 };
4327 entries.retain(|e| e.has_html);
4328 let total_scans = entries.len();
4329 let linked_count = query
4330 .linked
4331 .as_deref()
4332 .and_then(|s| s.parse::<usize>().ok())
4333 .unwrap_or(0);
4334 let browse_error = query.error.filter(|s| !s.is_empty());
4335 let template = HistoryTemplate {
4336 version: env!("CARGO_PKG_VERSION"),
4337 entries,
4338 total_scans,
4339 linked_count,
4340 browse_error,
4341 watched_dirs,
4342 csp_nonce,
4343 };
4344 Html(
4345 template
4346 .render()
4347 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4348 )
4349 .into_response()
4350}
4351
4352async fn compare_select_handler(
4353 State(state): State<AppState>,
4354 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4355) -> impl IntoResponse {
4356 auto_scan_watched_dirs(&state).await;
4357 let watched_dirs: Vec<String> = {
4358 let wd = state.watched_dirs.lock().await;
4359 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4360 };
4361 let mut entries = {
4362 let reg = state.registry.lock().await;
4363 make_history_rows(®)
4364 };
4365 entries.retain(|e| e.has_json);
4366 let total_scans = entries.len();
4367 let template = CompareSelectTemplate {
4368 version: env!("CARGO_PKG_VERSION"),
4369 entries,
4370 total_scans,
4371 watched_dirs,
4372 csp_nonce,
4373 };
4374 Html(
4375 template
4376 .render()
4377 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4378 )
4379 .into_response()
4380}
4381
4382#[derive(Deserialize, Default)]
4385struct CompareQuery {
4386 a: Option<String>,
4387 b: Option<String>,
4388 sub: Option<String>,
4390 scope: Option<String>,
4392}
4393
4394struct CompareFileDeltaRow {
4395 relative_path: String,
4396 language: String,
4397 status: String,
4398 baseline_code: i64,
4399 current_code: i64,
4400 code_delta_str: String,
4401 code_delta_class: String,
4402 comment_delta_str: String,
4403 comment_delta_class: String,
4404 total_delta_str: String,
4405 total_delta_class: String,
4406}
4407
4408fn recompute_summary_from_records(run: &mut AnalysisRun) {
4411 let files_analyzed = run
4412 .per_file_records
4413 .iter()
4414 .filter(|r| r.language.is_some())
4415 .count() as u64;
4416 let code_lines: u64 = run
4417 .per_file_records
4418 .iter()
4419 .map(|r| r.effective_counts.code_lines)
4420 .sum();
4421 let comment_lines: u64 = run
4422 .per_file_records
4423 .iter()
4424 .map(|r| r.effective_counts.comment_lines)
4425 .sum();
4426 let blank_lines: u64 = run
4427 .per_file_records
4428 .iter()
4429 .map(|r| r.effective_counts.blank_lines)
4430 .sum();
4431 run.summary_totals.files_analyzed = files_analyzed;
4432 run.summary_totals.files_considered = files_analyzed;
4433 run.summary_totals.code_lines = code_lines;
4434 run.summary_totals.comment_lines = comment_lines;
4435 run.summary_totals.blank_lines = blank_lines;
4436 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4437}
4438
4439fn fmt_delta(n: i64) -> String {
4440 if n > 0 {
4441 format!("+{n}")
4442 } else {
4443 format!("{n}")
4444 }
4445}
4446
4447fn delta_class(n: i64) -> &'static str {
4448 use std::cmp::Ordering;
4449 match n.cmp(&0) {
4450 Ordering::Greater => "pos",
4451 Ordering::Less => "neg",
4452 Ordering::Equal => "zero",
4453 }
4454}
4455
4456#[allow(clippy::cast_precision_loss)]
4458fn fmt_pct(delta: i64, baseline: u64) -> String {
4459 if baseline == 0 {
4460 return "—".to_string();
4461 }
4462 #[allow(clippy::cast_precision_loss)]
4463 let pct = (delta as f64 / baseline as f64) * 100.0;
4464 if pct > 0.049 {
4465 format!("+{pct:.1}%")
4466 } else if pct < -0.049 {
4467 format!("{pct:.1}%")
4468 } else {
4469 "±0%".to_string()
4470 }
4471}
4472
4473fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4475 prev.map_or_else(
4476 || ("—".to_string(), "na"),
4477 |p| {
4478 #[allow(clippy::cast_possible_wrap)]
4479 let d = curr as i64 - p as i64;
4480 (fmt_delta(d), delta_class(d))
4481 },
4482 )
4483}
4484
4485#[allow(clippy::result_large_err)] fn load_scan_for_compare(
4487 json_path: &std::path::Path,
4488 scan_label: &str,
4489 run_id: &str,
4490 server_mode: bool,
4491 compare_url: &str,
4492 csp_nonce: &str,
4493) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
4494 match read_json(json_path) {
4495 Ok(r) => Ok(r),
4496 Err(e) => {
4497 if server_mode {
4498 let html = ErrorTemplate {
4499 message: format!(
4500 "Could not load {scan_label} scan data. The scan output folder may have \
4501 been moved, renamed, or deleted. Re-running the analysis will create \
4502 fresh comparison data."
4503 ),
4504 last_report_url: Some("/compare-scans".to_string()),
4505 last_report_label: Some("Compare Scans".to_string()),
4506 csp_nonce: csp_nonce.to_owned(),
4507 }
4508 .render()
4509 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
4510 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
4511 }
4512 let msg = format!(
4513 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
4514 json_path.display()
4515 );
4516 let folder_hint = json_path
4517 .parent()
4518 .map(|p| p.display().to_string())
4519 .unwrap_or_default();
4520 Err(missing_scan_relocate_response(
4521 &msg,
4522 run_id,
4523 &folder_hint,
4524 compare_url,
4525 false,
4526 csp_nonce,
4527 ))
4528 }
4529 }
4530}
4531
4532struct ChurnStats {
4533 new_scope: bool,
4534 scope_flag: bool,
4535 churn_rate_str: String,
4536 churn_rate_class: String,
4537}
4538
4539fn compute_churn_stats(
4540 baseline_code: u64,
4541 current_code: u64,
4542 lines_added: i64,
4543 lines_removed: i64,
4544) -> ChurnStats {
4545 let new_scope = baseline_code == 0 && current_code > 0;
4546 #[allow(clippy::cast_precision_loss)]
4547 let churn_pct = if baseline_code > 0 {
4548 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
4549 } else {
4550 0.0
4551 };
4552 #[allow(clippy::cast_precision_loss)]
4553 let scope_flag =
4554 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
4555 let churn_rate_str = if new_scope {
4556 "New".to_string()
4557 } else if baseline_code > 0 {
4558 format!("{churn_pct:.1}%")
4559 } else {
4560 "—".to_string()
4561 };
4562 let churn_rate_class = if new_scope || churn_pct > 20.0 {
4563 "high".to_string()
4564 } else if churn_pct > 5.0 {
4565 "med".to_string()
4566 } else {
4567 "low".to_string()
4568 };
4569 ChurnStats {
4570 new_scope,
4571 scope_flag,
4572 churn_rate_str,
4573 churn_rate_class,
4574 }
4575}
4576
4577#[allow(clippy::too_many_lines)]
4578async fn compare_handler(
4579 State(state): State<AppState>,
4580 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4581 Query(query): Query<CompareQuery>,
4582) -> impl IntoResponse {
4583 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4586 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4587 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4588 };
4589
4590 let (maybe_a, maybe_b) = {
4591 let reg = state.registry.lock().await;
4592 (
4593 reg.find_by_run_id(&run_id_a).cloned(),
4594 reg.find_by_run_id(&run_id_b).cloned(),
4595 )
4596 };
4597
4598 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4599 let html = ErrorTemplate {
4600 message: "One or both run IDs were not found in scan history. \
4601 The runs may have been deleted or the registry may have been reset."
4602 .to_string(),
4603 last_report_url: Some("/compare-scans".to_string()),
4604 last_report_label: Some("Compare Scans".to_string()),
4605 csp_nonce: csp_nonce.clone(),
4606 }
4607 .render()
4608 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4609 return Html(html).into_response();
4610 };
4611
4612 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4614 (entry_a, entry_b)
4615 } else {
4616 (entry_b, entry_a)
4617 };
4618
4619 if baseline_entry.run_id != run_id_a {
4623 let canonical = format!(
4624 "/compare?a={}&b={}",
4625 baseline_entry.run_id, current_entry.run_id
4626 );
4627 return axum::response::Redirect::to(&canonical).into_response();
4628 }
4629
4630 let (Some(base_json), Some(curr_json)) = (
4631 baseline_entry.json_path.as_ref(),
4632 current_entry.json_path.as_ref(),
4633 ) else {
4634 let html = ErrorTemplate {
4635 message: "Full comparison requires JSON scan data, which was not saved for one or \
4636 both of these runs. JSON is now always saved for new scans — re-run the \
4637 affected projects to enable comparisons."
4638 .to_string(),
4639 last_report_url: Some("/compare-scans".to_string()),
4640 last_report_label: Some("Compare Scans".to_string()),
4641 csp_nonce: csp_nonce.clone(),
4642 }
4643 .render()
4644 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4645 return Html(html).into_response();
4646 };
4647
4648 let compare_url = format!(
4649 "/compare?a={}&b={}",
4650 baseline_entry.run_id, current_entry.run_id
4651 );
4652
4653 let baseline_run = match load_scan_for_compare(
4654 base_json,
4655 "baseline",
4656 &baseline_entry.run_id,
4657 state.server_mode,
4658 &compare_url,
4659 &csp_nonce,
4660 ) {
4661 Ok(r) => r,
4662 Err(resp) => return resp,
4663 };
4664 let current_run = match load_scan_for_compare(
4665 curr_json,
4666 "current",
4667 ¤t_entry.run_id,
4668 state.server_mode,
4669 &compare_url,
4670 &csp_nonce,
4671 ) {
4672 Ok(r) => r,
4673 Err(resp) => return resp,
4674 };
4675
4676 let active_submodule = query.sub.clone();
4677 let super_scope_active = query.scope.as_deref() == Some("super");
4678
4679 let submodule_options = baseline_run
4680 .submodule_summaries
4681 .iter()
4682 .chain(current_run.submodule_summaries.iter())
4683 .map(|s| s.name.clone())
4684 .collect::<std::collections::BTreeSet<_>>()
4685 .into_iter()
4686 .collect::<Vec<_>>();
4687 let has_any_submodule_data = !submodule_options.is_empty();
4688
4689 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4691 let mut b = baseline_run;
4692 let mut c = current_run;
4693 b.per_file_records
4694 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4695 c.per_file_records
4696 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4697 recompute_summary_from_records(&mut b);
4698 recompute_summary_from_records(&mut c);
4699 (b, c)
4700 } else if super_scope_active {
4701 let mut b = baseline_run;
4702 let mut c = current_run;
4703 b.per_file_records.retain(|f| f.submodule.is_none());
4704 c.per_file_records.retain(|f| f.submodule.is_none());
4705 recompute_summary_from_records(&mut b);
4706 recompute_summary_from_records(&mut c);
4707 (b, c)
4708 } else {
4709 (baseline_run, current_run)
4710 };
4711
4712 let comparison = compute_delta(&effective_baseline, &effective_current);
4713
4714 let file_rows: Vec<CompareFileDeltaRow> = comparison
4715 .file_deltas
4716 .iter()
4717 .map(|d| CompareFileDeltaRow {
4718 relative_path: d.relative_path.clone(),
4719 language: d.language.clone().unwrap_or_else(|| "—".into()),
4720 status: match d.status {
4721 FileChangeStatus::Added => "added".into(),
4722 FileChangeStatus::Removed => "removed".into(),
4723 FileChangeStatus::Modified => "modified".into(),
4724 FileChangeStatus::Unchanged => "unchanged".into(),
4725 },
4726 baseline_code: d.baseline_code,
4727 current_code: d.current_code,
4728 code_delta_str: fmt_delta(d.code_delta),
4729 code_delta_class: delta_class(d.code_delta).into(),
4730 comment_delta_str: fmt_delta(d.comment_delta),
4731 comment_delta_class: delta_class(d.comment_delta).into(),
4732 total_delta_str: fmt_delta(d.total_delta),
4733 total_delta_class: delta_class(d.total_delta).into(),
4734 })
4735 .collect();
4736
4737 let project_path = baseline_entry
4738 .input_roots
4739 .first()
4740 .map(|s| sanitize_path_str(s))
4741 .unwrap_or_default();
4742 let lines_added = sum_added_code_lines(&comparison);
4743 let lines_removed = sum_removed_code_lines(&comparison);
4744 let churn = compute_churn_stats(
4745 comparison.summary.baseline_code,
4746 comparison.summary.current_code,
4747 lines_added,
4748 lines_removed,
4749 );
4750 let s = &comparison.summary;
4751 let template = CompareTemplate {
4752 version: env!("CARGO_PKG_VERSION"),
4753 project_label: baseline_entry.project_label.clone(),
4754 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4755 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4756 baseline_run_id: baseline_entry.run_id.clone(),
4757 current_run_id: current_entry.run_id.clone(),
4758 baseline_run_id_short: baseline_entry
4759 .run_id
4760 .split('-')
4761 .next_back()
4762 .unwrap_or(&baseline_entry.run_id)
4763 .chars()
4764 .take(7)
4765 .collect(),
4766 current_run_id_short: current_entry
4767 .run_id
4768 .split('-')
4769 .next_back()
4770 .unwrap_or(¤t_entry.run_id)
4771 .chars()
4772 .take(7)
4773 .collect(),
4774 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
4775 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
4776 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
4777 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
4778 project_path: project_path.clone(),
4779 baseline_code: s.baseline_code,
4780 current_code: s.current_code,
4781 code_lines_delta_str: fmt_delta(s.code_lines_delta),
4782 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
4783 baseline_files: s.baseline_files,
4784 current_files: s.current_files,
4785 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
4786 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
4787 baseline_comments: s.baseline_comments,
4788 current_comments: s.current_comments,
4789 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
4790 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
4791 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
4792 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
4793 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
4794 code_lines_added: lines_added,
4795 code_lines_removed: lines_removed,
4796 new_scope: churn.new_scope,
4797 churn_rate_str: churn.churn_rate_str,
4798 churn_rate_class: churn.churn_rate_class,
4799 scope_flag: churn.scope_flag,
4800 files_added: comparison.files_added,
4801 files_removed: comparison.files_removed,
4802 files_modified: comparison.files_modified,
4803 files_unchanged: comparison.files_unchanged,
4804 file_rows,
4805 baseline_git_author: baseline_entry.git_author.clone(),
4806 current_git_author: current_entry.git_author.clone(),
4807 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
4808 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
4809 baseline_git_tags: baseline_entry.git_tags.clone(),
4810 current_git_tags: current_entry.git_tags.clone(),
4811 baseline_git_commit_date: baseline_entry
4812 .git_commit_date
4813 .as_deref()
4814 .and_then(fmt_git_date),
4815 current_git_commit_date: current_entry
4816 .git_commit_date
4817 .as_deref()
4818 .and_then(fmt_git_date),
4819 project_name: project_path
4820 .rsplit(['/', '\\'])
4821 .find(|s| !s.is_empty())
4822 .unwrap_or(&project_path)
4823 .to_string(),
4824 submodule_options,
4825 has_any_submodule_data,
4826 active_submodule,
4827 super_scope_active,
4828 csp_nonce,
4829 };
4830
4831 Html(
4832 template
4833 .render()
4834 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4835 )
4836 .into_response()
4837}
4838
4839fn format_number(n: u64) -> String {
4847 let s = n.to_string();
4848 let mut out = String::with_capacity(s.len() + s.len() / 3);
4849 let len = s.len();
4850 for (i, c) in s.chars().enumerate() {
4851 if i > 0 && (len - i).is_multiple_of(3) {
4852 out.push(',');
4853 }
4854 out.push(c);
4855 }
4856 out
4857}
4858
4859const fn badge_char_width(c: char) -> f64 {
4860 match c {
4861 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
4862 'm' | 'w' => 9.0,
4863 ' ' => 4.0,
4864 _ => 6.5,
4865 }
4866}
4867
4868#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4869fn badge_text_px(text: &str) -> u32 {
4870 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
4871}
4872
4873fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
4874 let lw = badge_text_px(label) + 20;
4875 let rw = badge_text_px(value) + 20;
4876 let total = lw + rw;
4877 let lx = lw / 2;
4878 let rx = lw + rw / 2;
4879 let le = escape_html(label);
4880 let ve = escape_html(value);
4881 let ce = escape_html(color);
4882 format!(
4883 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
4884 <rect width="{total}" height="20" fill="#555"/>
4885 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
4886 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
4887 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
4888 <text x="{lx}" y="13">{le}</text>
4889 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
4890 <text x="{rx}" y="13">{ve}</text>
4891 </g>
4892</svg>"##
4893 )
4894}
4895
4896#[derive(Deserialize)]
4897struct BadgeQuery {
4898 label: Option<String>,
4899 color: Option<String>,
4900}
4901
4902async fn badge_handler(
4903 State(state): State<AppState>,
4904 AxumPath(metric): AxumPath<String>,
4905 Query(query): Query<BadgeQuery>,
4906) -> Response {
4907 let entry = {
4908 let reg = state.registry.lock().await;
4909 reg.entries.first().cloned()
4910 };
4911
4912 let Some(entry) = entry else {
4913 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
4914 return (
4915 [
4916 (header::CONTENT_TYPE, "image/svg+xml"),
4917 (header::CACHE_CONTROL, "no-cache, max-age=0"),
4918 ],
4919 svg,
4920 )
4921 .into_response();
4922 };
4923
4924 let (default_label, value, default_color) = match metric.as_str() {
4925 "code-lines" => (
4926 "code lines",
4927 format_number(entry.summary.code_lines),
4928 "#4a78ee",
4929 ),
4930 "files" => (
4931 "files analyzed",
4932 format_number(entry.summary.files_analyzed),
4933 "#4a9862",
4934 ),
4935 "comment-lines" => (
4936 "comment lines",
4937 format_number(entry.summary.comment_lines),
4938 "#b35428",
4939 ),
4940 "blank-lines" => (
4941 "blank lines",
4942 format_number(entry.summary.blank_lines),
4943 "#7a5db0",
4944 ),
4945 _ => return StatusCode::NOT_FOUND.into_response(),
4946 };
4947
4948 let label = query.label.as_deref().unwrap_or(default_label);
4949 let color = query.color.as_deref().unwrap_or(default_color);
4950 let svg = render_badge_svg(label, &value, color);
4951
4952 (
4953 [
4954 (header::CONTENT_TYPE, "image/svg+xml"),
4955 (header::CACHE_CONTROL, "no-cache, max-age=0"),
4956 ],
4957 svg,
4958 )
4959 .into_response()
4960}
4961
4962#[derive(Serialize)]
4970struct ApiMetricsResponse {
4971 run_id: String,
4972 timestamp: String,
4973 project: String,
4974 summary: ApiSummaryPayload,
4975 languages: Vec<ApiLanguageRow>,
4976}
4977
4978#[derive(Serialize)]
4979struct ApiSummaryPayload {
4980 files_analyzed: u64,
4981 files_skipped: u64,
4982 code_lines: u64,
4983 comment_lines: u64,
4984 blank_lines: u64,
4985 total_physical_lines: u64,
4986 functions: u64,
4987 classes: u64,
4988 variables: u64,
4989 imports: u64,
4990}
4991
4992#[derive(Serialize)]
4993struct ApiLanguageRow {
4994 name: String,
4995 files: u64,
4996 code_lines: u64,
4997 comment_lines: u64,
4998 blank_lines: u64,
4999 functions: u64,
5000 classes: u64,
5001 variables: u64,
5002 imports: u64,
5003}
5004
5005async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5006 let entry = {
5007 let reg = state.registry.lock().await;
5008 reg.entries.first().cloned()
5009 };
5010 entry.map_or_else(
5011 || error::not_found("no scans recorded yet"),
5012 |e| build_metrics_response(&e),
5013 )
5014}
5015
5016async fn api_metrics_run_handler(
5017 State(state): State<AppState>,
5018 AxumPath(run_id): AxumPath<String>,
5019) -> Response {
5020 let entry = {
5021 let reg = state.registry.lock().await;
5022 reg.find_by_run_id(&run_id).cloned()
5023 };
5024 entry.map_or_else(
5025 || error::not_found("run not found"),
5026 |e| build_metrics_response(&e),
5027 )
5028}
5029
5030fn build_metrics_response(entry: &RegistryEntry) -> Response {
5031 let languages: Vec<ApiLanguageRow> = entry
5032 .json_path
5033 .as_ref()
5034 .and_then(|p| read_json(p).ok())
5035 .map(|run| {
5036 run.totals_by_language
5037 .iter()
5038 .map(|l| ApiLanguageRow {
5039 name: l.language.display_name().to_string(),
5040 files: l.files,
5041 code_lines: l.code_lines,
5042 comment_lines: l.comment_lines,
5043 blank_lines: l.blank_lines,
5044 functions: l.functions,
5045 classes: l.classes,
5046 variables: l.variables,
5047 imports: l.imports,
5048 })
5049 .collect()
5050 })
5051 .unwrap_or_default();
5052
5053 let s = &entry.summary;
5054 Json(ApiMetricsResponse {
5055 run_id: entry.run_id.clone(),
5056 timestamp: entry.timestamp_utc.to_rfc3339(),
5057 project: entry.project_label.clone(),
5058 summary: ApiSummaryPayload {
5059 files_analyzed: s.files_analyzed,
5060 files_skipped: s.files_skipped,
5061 code_lines: s.code_lines,
5062 comment_lines: s.comment_lines,
5063 blank_lines: s.blank_lines,
5064 total_physical_lines: s.total_physical_lines,
5065 functions: s.functions,
5066 classes: s.classes,
5067 variables: s.variables,
5068 imports: s.imports,
5069 },
5070 languages,
5071 })
5072 .into_response()
5073}
5074
5075#[derive(Deserialize)]
5082struct ProjectHistoryQuery {
5083 path: Option<String>,
5084}
5085
5086#[derive(Serialize)]
5087struct ProjectHistoryResponse {
5088 scan_count: usize,
5089 last_scan_id: Option<String>,
5090 last_scan_timestamp: Option<String>,
5091 last_scan_code_lines: Option<u64>,
5092 last_git_branch: Option<String>,
5093 last_git_commit: Option<String>,
5094}
5095
5096async fn project_history_handler(
5097 State(state): State<AppState>,
5098 Query(query): Query<ProjectHistoryQuery>,
5099) -> Response {
5100 let path = query.path.unwrap_or_default();
5101 let resolved = resolve_input_path(&path);
5102 let root_str = resolved.to_string_lossy().replace('\\', "/");
5103
5104 let entries: Vec<_> = {
5105 let reg = state.registry.lock().await;
5106 reg.entries
5107 .iter()
5108 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5109 .cloned()
5110 .collect()
5111 };
5112 let scan_count = entries.len();
5113 let last = entries.first();
5114 let last_scan_id = last.map(|e| e.run_id.clone());
5115 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5116 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5117 let last_git_branch = last.and_then(|e| e.git_branch.clone());
5118 let last_git_commit = last.and_then(|e| e.git_commit.clone());
5119
5120 Json(ProjectHistoryResponse {
5121 scan_count,
5122 last_scan_id,
5123 last_scan_timestamp,
5124 last_scan_code_lines,
5125 last_git_branch,
5126 last_git_commit,
5127 })
5128 .into_response()
5129}
5130
5131#[derive(Deserialize)]
5138struct MetricsHistoryQuery {
5139 root: Option<String>,
5140 limit: Option<usize>,
5141 submodule: Option<String>,
5144}
5145
5146#[derive(Serialize)]
5147struct MetricsSubmoduleLink {
5148 name: String,
5149 url: String,
5150}
5151
5152#[derive(Serialize)]
5153struct MetricsHistoryEntry {
5154 run_id: String,
5155 run_id_short: String,
5156 timestamp: String,
5157 commit: Option<String>,
5158 branch: Option<String>,
5159 tags: Vec<String>,
5160 nearest_tag: Option<String>,
5161 code_lines: u64,
5162 comment_lines: u64,
5163 blank_lines: u64,
5164 physical_lines: u64,
5165 files_analyzed: u64,
5166 files_skipped: u64,
5167 test_count: u64,
5168 project_label: String,
5169 html_url: Option<String>,
5170 has_pdf: bool,
5171 submodule_links: Vec<MetricsSubmoduleLink>,
5172}
5173
5174fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
5175 let mut links: Vec<MetricsSubmoduleLink> = vec![];
5176 let sub_dir = e
5177 .html_path
5178 .as_ref()
5179 .and_then(|p| p.parent())
5180 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5181 let Some(dir) = sub_dir else { return links };
5182 let Ok(rd) = std::fs::read_dir(dir) else {
5183 return links;
5184 };
5185 for entry_res in rd.flatten() {
5186 let fname = entry_res.file_name();
5187 let fname_str = fname.to_string_lossy();
5188 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5189 let stem = &fname_str[..fname_str.len() - 5];
5190 let display = stem[4..].replace('-', " ");
5191 links.push(MetricsSubmoduleLink {
5192 name: display,
5193 url: format!("/runs/{stem}/{}", e.run_id),
5194 });
5195 }
5196 }
5197 links.sort_by(|a, b| a.name.cmp(&b.name));
5198 links
5199}
5200
5201fn apply_submodule_filter(
5202 base: MetricsHistoryEntry,
5203 filter: &str,
5204 e: &sloc_core::history::RegistryEntry,
5205) -> Option<MetricsHistoryEntry> {
5206 let json_path = e.json_path.as_ref()?;
5207 let json_str = std::fs::read_to_string(json_path).ok()?;
5208 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5209 let sub = run
5210 .submodule_summaries
5211 .iter()
5212 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
5213 let safe = sanitize_project_label(&sub.name);
5214 let artifact_key = format!("sub_{safe}");
5215 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
5216 || base.html_url.clone(),
5217 |run_dir| {
5218 let sub_path = run_dir.join(format!("{artifact_key}.html"));
5219 if sub_path.exists() {
5220 Some(format!("/runs/{artifact_key}/{}", e.run_id))
5221 } else {
5222 base.html_url.clone()
5223 }
5224 },
5225 );
5226 Some(MetricsHistoryEntry {
5227 code_lines: sub.code_lines,
5228 comment_lines: sub.comment_lines,
5229 blank_lines: sub.blank_lines,
5230 physical_lines: sub.total_physical_lines,
5231 files_analyzed: sub.files_analyzed,
5232 html_url: sub_html_url,
5233 has_pdf: false,
5234 submodule_links: vec![],
5235 ..base
5236 })
5237}
5238
5239#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
5241 State(state): State<AppState>,
5242 Query(query): Query<MetricsHistoryQuery>,
5243) -> Response {
5244 let limit = query.limit.unwrap_or(50).min(500);
5245 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5246
5247 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5248 let reg = state.registry.lock().await;
5249 reg.entries
5250 .iter()
5251 .filter(|e| {
5252 query.root.as_ref().is_none_or(|root| {
5253 let resolved = resolve_input_path(root);
5254 let root_str = resolved.to_string_lossy().replace('\\', "/");
5255 e.input_roots.iter().any(|r| r == &root_str)
5256 })
5257 })
5258 .take(limit)
5259 .cloned()
5260 .collect()
5261 };
5262
5263 let entries: Vec<MetricsHistoryEntry> = candidate_entries
5264 .into_iter()
5265 .filter_map(|e| {
5266 let tags = e
5267 .git_tags
5268 .as_deref()
5269 .map(|s| {
5270 s.split(',')
5271 .map(|t| t.trim().to_string())
5272 .filter(|t| !t.is_empty())
5273 .collect()
5274 })
5275 .unwrap_or_default();
5276 let html_url = e
5277 .html_path
5278 .as_ref()
5279 .filter(|p| p.exists())
5280 .map(|_| format!("/runs/html/{}", e.run_id));
5281 let nearest_tag = e.git_nearest_tag.clone();
5282 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5283 let run_id_short: String = e
5284 .run_id
5285 .split('-')
5286 .next_back()
5287 .unwrap_or(&e.run_id)
5288 .chars()
5289 .take(7)
5290 .collect();
5291 let submodule_links = build_entry_submodule_links(&e);
5292 let base = MetricsHistoryEntry {
5293 run_id: e.run_id.clone(),
5294 run_id_short,
5295 timestamp: e.timestamp_utc.to_rfc3339(),
5296 commit: e.git_commit.clone(),
5297 branch: e.git_branch.clone(),
5298 tags,
5299 nearest_tag,
5300 code_lines: e.summary.code_lines,
5301 comment_lines: e.summary.comment_lines,
5302 blank_lines: e.summary.blank_lines,
5303 physical_lines: e.summary.total_physical_lines,
5304 files_analyzed: e.summary.files_analyzed,
5305 files_skipped: e.summary.files_skipped,
5306 test_count: e.summary.test_count,
5307 project_label: e.project_label.clone(),
5308 html_url,
5309 has_pdf,
5310 submodule_links,
5311 };
5312 if let Some(ref filter) = submodule_filter {
5313 apply_submodule_filter(base, filter, &e)
5314 } else {
5315 Some(base)
5316 }
5317 })
5318 .collect();
5319
5320 Json(entries).into_response()
5321}
5322
5323#[derive(Deserialize)]
5327struct MetricsSubmodulesQuery {
5328 root: Option<String>,
5329}
5330
5331#[derive(Serialize)]
5332struct SubmoduleEntry {
5333 name: String,
5334 relative_path: String,
5335}
5336
5337async fn api_metrics_submodules_handler(
5338 State(state): State<AppState>,
5339 Query(query): Query<MetricsSubmodulesQuery>,
5340) -> Response {
5341 let json_paths: Vec<std::path::PathBuf> = {
5342 let reg = state.registry.lock().await;
5343 reg.entries
5344 .iter()
5345 .filter(|e| {
5346 query.root.as_ref().is_none_or(|root| {
5347 let resolved = resolve_input_path(root);
5348 let root_str = resolved.to_string_lossy().replace('\\', "/");
5349 e.input_roots.iter().any(|r| r == &root_str)
5350 })
5351 })
5352 .filter_map(|e| e.json_path.clone())
5353 .collect()
5354 };
5355
5356 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5357 let mut result: Vec<SubmoduleEntry> = Vec::new();
5358
5359 for path in &json_paths {
5360 let Ok(json_str) = std::fs::read_to_string(path) else {
5361 continue;
5362 };
5363 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5364 continue;
5365 };
5366 for sub in &run.submodule_summaries {
5367 if seen.insert(sub.name.clone()) {
5368 result.push(SubmoduleEntry {
5369 name: sub.name.clone(),
5370 relative_path: sub.relative_path.clone(),
5371 });
5372 }
5373 }
5374 }
5375
5376 result.sort_by(|a, b| a.name.cmp(&b.name));
5377 Json(result).into_response()
5378}
5379
5380#[derive(Deserialize)]
5389struct IngestQuery {
5390 label: Option<String>,
5391}
5392
5393#[derive(Serialize)]
5394struct IngestResponse {
5395 run_id: String,
5396 view_url: String,
5397}
5398
5399async fn api_ingest_handler(
5400 State(state): State<AppState>,
5401 Query(q): Query<IngestQuery>,
5402 Json(run): Json<sloc_core::AnalysisRun>,
5403) -> Response {
5404 let label = q.label.unwrap_or_else(|| {
5405 run.input_roots
5406 .first()
5407 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5408 });
5409
5410 let label_for_task = label.clone();
5411 let result = tokio::task::spawn_blocking(move || {
5412 let html = render_html(&run)?;
5413 let run_id = run.tool.run_id.clone();
5414 let run_id_safe = run_id.len() <= 128
5415 && !run_id.is_empty()
5416 && run_id
5417 .chars()
5418 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5419 if !run_id_safe {
5420 anyhow::bail!(
5421 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5422 );
5423 }
5424 let project_label = sanitize_project_label(&label_for_task);
5425 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5426 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5427 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5428 _ => project_label,
5429 };
5430 let (artifacts, _pending_pdf) = persist_run_artifacts(
5431 &run,
5432 &html,
5433 &output_dir,
5434 true,
5435 true,
5436 false,
5437 &label_for_task,
5438 &file_stem,
5439 RunResultContext::default(),
5440 )?;
5441 Ok::<_, anyhow::Error>((run_id, artifacts, run))
5442 })
5443 .await;
5444
5445 match result {
5446 Ok(Ok((run_id, artifacts, run))) => {
5447 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5448 (
5449 StatusCode::CREATED,
5450 Json(IngestResponse {
5451 view_url: format!("/view-reports?run_id={run_id}"),
5452 run_id,
5453 }),
5454 )
5455 .into_response()
5456 }
5457 Ok(Err(e)) => error::internal(&format!("{e:#}")),
5458 Err(e) => error::internal(&format!("{e}")),
5459 }
5460}
5461
5462#[allow(clippy::too_many_lines)] async fn trend_report_handler(
5470 State(state): State<AppState>,
5471 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5472) -> Response {
5473 auto_scan_watched_dirs(&state).await;
5474
5475 let watched_dirs_list: Vec<String> = {
5476 let wd = state.watched_dirs.lock().await;
5477 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5478 };
5479
5480 let roots: Vec<String> = {
5482 let reg = state.registry.lock().await;
5483 let mut seen = std::collections::BTreeSet::new();
5484 reg.entries
5485 .iter()
5486 .flat_map(|e| e.input_roots.iter().cloned())
5487 .filter(|r| seen.insert(r.clone()))
5488 .collect()
5489 };
5490
5491 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5492 let nonce = &csp_nonce;
5493 let version = env!("CARGO_PKG_VERSION");
5494
5495 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5497 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5498 .to_string()
5499 } else {
5500 watched_dirs_list
5501 .iter()
5502 .fold(String::new(), |mut s, d| {
5503 use std::fmt::Write as _;
5504 let escaped = d.replace('&', "&").replace('"', """).replace('<', "<");
5505 write!(
5506 s,
5507 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>"#
5508 ).expect("write to String is infallible");
5509 s
5510 })
5511 };
5512 let watched_dirs_html = format!(
5513 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>"#
5514 );
5515
5516 let html = format!(
5517 r##"<!doctype html>
5518<html lang="en">
5519<head>
5520 <meta charset="utf-8" />
5521 <meta name="viewport" content="width=device-width, initial-scale=1" />
5522 <title>OxideSLOC | Trend Reports</title>
5523 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5524 <style nonce="{nonce}">
5525 :root {{
5526 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5527 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5528 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5529 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5530 --info-bg:#eef3ff; --info-text:#4467d8;
5531 }}
5532 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5533 *{{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);}}
5534 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5535 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5536 .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;}}
5537 @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));}}}}
5538 .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);}}
5539 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5540 .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));}}
5541 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5542 .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;}}
5543 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5544 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5545 @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; }} }}
5546 .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;}}
5547 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5548 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5549 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5550 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5551 .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;}}
5552 .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;}}
5553 .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;}}
5554 .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;}}
5555 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5556 .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);}}
5557 .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;}}
5558 .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;}}
5559 .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;}}
5560 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5561 .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;}}
5562 .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);}}
5563 .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;}}
5564 .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;}}
5565 .tz-select:focus{{border-color:var(--oxide);}}
5566 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5567 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5568 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5569 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5570 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5571 .trend-title-block{{flex:1;min-width:0;}}
5572 .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;}}
5573 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5574 .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;}}
5575 .chart-select:focus{{border-color:var(--accent);}}
5576 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5577 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5578 .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;}}
5579 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5580 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5581 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5582 .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);}}
5583 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5584 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5585 .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;}}
5586 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5587 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5588 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5589 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5590 .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;}}
5591 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5592 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5593 .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);}}
5594 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5595 .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;}}
5596 .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;}}
5597 .data-table tr:last-child td{{border-bottom:none;}}
5598 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5599 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5600 .table-wrap{{width:100%;overflow-x:auto;}}
5601 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5602 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5603 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5604 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5605 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5606 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5607 .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;}}
5608 .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;}}
5609 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5610 .pagination-info{{font-size:13px;color:var(--muted);}}
5611 .pagination-btns{{display:flex;gap:6px;}}
5612 .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;}}
5613 .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;}}
5614 #scan-history-table col:nth-child(1){{width:155px;}}
5615 #scan-history-table col:nth-child(2){{width:240px;}}
5616 #scan-history-table col:nth-child(3){{width:82px;}}
5617 #scan-history-table col:nth-child(4){{width:82px;}}
5618 #scan-history-table col:nth-child(5){{width:90px;}}
5619 #scan-history-table col:nth-child(6){{width:90px;}}
5620 #scan-history-table col:nth-child(7){{width:88px;}}
5621 #scan-history-table col:nth-child(8){{width:150px;}}
5622 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5623 .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;}}
5624 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;flex-wrap:wrap;margin-bottom:16px;position:relative;z-index:1;}}
5625 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5626 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5627 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5628 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5629 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5630 .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;}}
5631 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5632 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5633 .watched-chip-rm:hover{{color:var(--oxide);}}
5634 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5635 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5636 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
5637 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5638 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5639 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5640 a.run-link:hover{{text-decoration:underline;}}
5641 .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);}}
5642 .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);}}
5643 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5644 .metric-num{{font-weight:700;color:var(--text);}}
5645 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5646 .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;}}
5647 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5648 .btn.primary:hover{{opacity:.9;}}
5649 .rpt-btn{{min-width:58px;justify-content:center;}}
5650 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5651 .report-cell{{overflow:visible!important;white-space:normal!important;}}
5652 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5653 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5654 .submod-details summary::-webkit-details-marker{{display:none;}}
5655 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5656 .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;}}
5657 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5658 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5659 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5660 .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;}}
5661 .export-btn:hover{{background:var(--line);}}
5662 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5663 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5664 .site-footer a{{color:var(--muted);}}
5665 .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;}}
5666 .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;}}
5667 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5668 </style>
5669</head>
5670<body>
5671 <div class="background-watermarks" aria-hidden="true">
5672 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5673 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5674 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5675 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5676 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5677 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5678 </div>
5679 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5680 <div class="top-nav">
5681 <div class="top-nav-inner">
5682 <a class="brand" href="/">
5683 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5684 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5685 </a>
5686 <div class="nav-right">
5687 <a class="nav-pill" href="/">Home</a>
5688 <div class="nav-dropdown">
5689 <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>
5690 <div class="nav-dropdown-menu">
5691 <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>
5692 </div>
5693 </div>
5694 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5695 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5696 <div class="nav-dropdown">
5697 <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>
5698 <div class="nav-dropdown-menu">
5699 <a href="/webhook-setup"><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>
5700 </div>
5701 </div>
5702 <div class="server-status-wrap">
5703 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5704 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
5705 </div>
5706 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5707 <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>
5708 </button>
5709 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5710 <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>
5711 <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>
5712 </button>
5713 </div>
5714 </div>
5715 </div>
5716
5717 <div class="page">
5718 {watched_dirs_html}
5719 <div class="summary-strip" id="trend-stats"></div>
5720 <div class="panel">
5721 <div class="trend-header">
5722 <div class="trend-title-block">
5723 <h1>Trend Reports</h1>
5724 <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>
5725 <span class="chart-hint-inline">
5726 <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>
5727 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
5728 </span>
5729 </div>
5730 <div class="chart-actions">
5731 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5732 <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>
5733 Export Excel
5734 </button>
5735 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5736 <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>
5737 Export PNG
5738 </button>
5739 </div>
5740 </div>
5741
5742 <div class="controls-centered">
5743 <label>Project Root:
5744 <select class="chart-select" id="root-sel">
5745 <option value="">All projects</option>
5746 </select>
5747 </label>
5748 <label>Y Metric:
5749 <select class="chart-select" id="y-sel">
5750 <option value="code_lines">Code Lines</option>
5751 <option value="comment_lines">Comment Lines</option>
5752 <option value="blank_lines">Blank Lines</option>
5753 <option value="physical_lines">Physical Lines</option>
5754 <option value="files_analyzed">Files Analyzed</option>
5755 </select>
5756 </label>
5757 <label>X Axis:
5758 <select class="chart-select" id="x-sel">
5759 <option value="time">By Time</option>
5760 <option value="commit">By Commit</option>
5761 <option value="release">By Release</option>
5762 <option value="tag">Tagged Commits</option>
5763 </select>
5764 </label>
5765 <label id="submodule-label" style="display:none;">Submodule:
5766 <select class="chart-select" id="sub-sel">
5767 <option value="">All (project total)</option>
5768 </select>
5769 </label>
5770 <label>Chart Size:
5771 <select class="chart-select" id="scale-sel">
5772 <option value="0.75">Compact</option>
5773 <option value="1.2" selected>Normal</option>
5774 <option value="1.38">Large</option>
5775 </select>
5776 </label>
5777 </div>
5778
5779 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
5780 <div id="data-table-wrap" style="overflow-x:auto;"></div>
5781 </div>
5782 </div>
5783
5784 <script nonce="{nonce}">
5785 (function() {{
5786 // Theme persistence
5787 var b = document.body;
5788 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
5789 var tgl = document.getElementById('theme-toggle');
5790 if (tgl) tgl.addEventListener('click', function() {{
5791 var d = b.classList.toggle('dark-theme');
5792 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
5793 }});
5794
5795 // Watermark randomizer
5796 (function() {{
5797 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5798 if (!wms.length) return;
5799 var placed = [];
5800 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;}}
5801 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];}}
5802 var half=Math.floor(wms.length/2);
5803 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;}});
5804 }})();
5805
5806 // Code particles
5807 (function() {{
5808 var container = document.getElementById('code-particles');
5809 if (!container) return;
5810 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'];
5811 for (var i = 0; i < 38; i++) {{
5812 (function(idx) {{
5813 var el = document.createElement('span');
5814 el.className = 'code-particle';
5815 el.textContent = snippets[idx % snippets.length];
5816 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
5817 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
5818 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
5819 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';
5820 container.appendChild(el);
5821 }})(i);
5822 }}
5823 }})();
5824
5825 // Watched folder picker
5826 (function() {{
5827 var btn = document.getElementById('add-watched-btn');
5828 if (!btn) return;
5829 btn.addEventListener('click', function() {{
5830 fetch('/pick-directory?kind=reports')
5831 .then(function(r) {{ return r.json(); }})
5832 .then(function(data) {{
5833 if (!data.cancelled && data.selected_path) {{
5834 var form = document.createElement('form');
5835 form.method = 'POST';
5836 form.action = '/watched-dirs/add';
5837 var ri = document.createElement('input');
5838 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
5839 var fi = document.createElement('input');
5840 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
5841 form.appendChild(ri); form.appendChild(fi);
5842 document.body.appendChild(form);
5843 form.submit();
5844 }}
5845 }})
5846 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
5847 }});
5848 }})();
5849
5850 // Settings / color-scheme modal
5851 (function() {{
5852 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'}}];
5853 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);}});}}
5854 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
5855 var btn=document.getElementById('settings-btn');if(!btn)return;
5856 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
5857 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>';
5858 document.body.appendChild(m);
5859 var g=document.getElementById('scheme-grid');
5860 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);}});
5861 var cl=document.getElementById('settings-close');
5862 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);
5863 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');}});
5864 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
5865 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
5866 }})();
5867 }})();
5868
5869 var ROOTS = {roots_json};
5870 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
5871 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
5872 var allData = [];
5873
5874 // Populate root selector
5875 var rootSel = document.getElementById('root-sel');
5876 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
5877
5878 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();}}
5879 function fmtFull(n){{return Number(n).toLocaleString();}}
5880 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
5881
5882 // Tooltip
5883 var tt = document.createElement('div');
5884 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);';
5885 document.body.appendChild(tt);
5886 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
5887 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';}}
5888 function hideTT(){{tt.style.display='none';}}
5889
5890 function statExact(compact, full){{
5891 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
5892 }}
5893 function statVal(n){{
5894 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
5895 }}
5896
5897 function updateStats(data){{
5898 var statsEl=document.getElementById('trend-stats');
5899 if(!statsEl)return;
5900 if(!data||!data.length){{statsEl.innerHTML='';return;}}
5901 var yKey=document.getElementById('y-sel').value;
5902 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
5903 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
5904 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
5905 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
5906 var absDelta=Math.abs(delta);
5907 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
5908 var deltaExact=statExact(deltaCompact,deltaFull);
5909 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
5910 statsEl.innerHTML=
5911 '<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>'+
5912 '<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>'+
5913 '<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>'+
5914 '<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>';
5915 }}
5916
5917 var subSel = document.getElementById('sub-sel');
5918 var subLabel = document.getElementById('submodule-label');
5919
5920 function populateSubmodules(root){{
5921 if(!subSel||!subLabel)return;
5922 while(subSel.options.length>1)subSel.remove(1);
5923 subSel.value='';
5924 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
5925 fetch(url)
5926 .then(function(r){{return r.json();}})
5927 .then(function(subs){{
5928 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
5929 subs.forEach(function(s){{
5930 var o=document.createElement('option');
5931 o.value=s.name;
5932 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
5933 subSel.appendChild(o);
5934 }});
5935 subLabel.style.display='';
5936 }})
5937 .catch(function(){{subLabel.style.display='none';}});
5938 }}
5939
5940 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
5941
5942 function loadAndRender(){{
5943 var root = rootSel.value;
5944 var sub = subSel ? subSel.value : '';
5945 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
5946 document.getElementById('data-table-wrap').innerHTML='';
5947 var url = '/api/metrics/history?limit=100'
5948 + (root ? '&root='+encodeURIComponent(root) : '')
5949 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
5950 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
5951 allData = data;
5952 render(data);
5953 updateStats(data);
5954 }}).catch(function(){{
5955 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>';
5956 }});
5957 }}
5958
5959 function render(data){{
5960 var yKey = document.getElementById('y-sel').value;
5961 var xMode = document.getElementById('x-sel').value;
5962
5963 // Filter for tag/release mode
5964 var pts = data;
5965 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
5966
5967 // Sort oldest-first for the line chart
5968 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
5969
5970 var wrap = document.getElementById('chart-wrap');
5971 if(!pts.length){{
5972 var emptyMsg = (xMode === 'tag')
5973 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
5974 : 'No scan data found for the selected filters.';
5975 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
5976 renderTable([]);
5977 return;
5978 }}
5979
5980 var scaleEl=document.getElementById('scale-sel');
5981 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
5982 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;
5983 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
5984
5985 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
5986
5987 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">';
5988 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>';
5989
5990 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
5991
5992 // Grid + Y axis ticks
5993 for(var ti=0;ti<=5;ti++){{
5994 var gy=PT+CH-Math.round(ti/5*CH);
5995 var gv=Math.round(ti/5*maxY);
5996 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
5997 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
5998 }}
5999
6000 // X axis labels (every N-th point to avoid crowding)
6001 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6002 pts.forEach(function(d,i){{
6003 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6004 if(i%labelEvery===0||i===pts.length-1){{
6005 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)));
6006 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>';
6007 }}
6008 }});
6009
6010 // Axis label
6011 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6012 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>';
6013 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>';
6014
6015 // Area fill + line path
6016 var pathD='';
6017 pts.forEach(function(d,i){{
6018 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6019 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6020 pathD+=(i===0?'M':'L')+x+','+y;
6021 }});
6022 if(pts.length>1){{
6023 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6024 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6025 }}
6026 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6027
6028 // Data points (clickable) + permanent value labels
6029 var showLabels = pts.length <= 40;
6030 var labelEveryN = pts.length > 20 ? 2 : 1;
6031 pts.forEach(function(d,i){{
6032 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6033 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6034 var hasTags=d.tags&&d.tags.length>0;
6035 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6036 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6037 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+'"/>';
6038 if(showLabels && i%labelEveryN===0){{
6039 var lx=x, ly=y-r-5;
6040 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>';
6041 }}
6042 }});
6043
6044 svg+='</svg>';
6045 wrap.innerHTML=svg;
6046
6047 // Attach point tooltips
6048 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6049 c.addEventListener('mouseover',function(e){{
6050 var d=pts[parseInt(this.dataset.idx)];
6051 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(''):'';
6052 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>':'';
6053 showTT(e,
6054 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6055 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6056 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6057 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6058 );
6059 this.setAttribute('r','8');
6060 }});
6061 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6062 c.addEventListener('mousemove',moveTT);
6063 c.addEventListener('click',function(){{
6064 var d=pts[parseInt(this.dataset.idx)];
6065 if(d.html_url) window.open(d.html_url,'_blank');
6066 }});
6067 }});
6068
6069 renderTable(pts, yKey);
6070 }}
6071
6072 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6073 var shProjFilter='', shBranchFilter='';
6074
6075 function fmtPST(isoStr){{
6076 if(!isoStr)return'';
6077 var d=new Date(isoStr);
6078 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6079 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);}}
6080 function p(n){{return n<10?'0'+n:String(n);}}
6081 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++;}}}}
6082 var yr=d.getUTCFullYear();
6083 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6084 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6085 var isDST=d>=dstStart&&d<dstEnd;
6086 var off=isDST?-7*3600*1000:-8*3600*1000;
6087 var lbl=isDST?'PDT':'PST';
6088 var loc=new Date(d.getTime()+off);
6089 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6090 }}
6091
6092 function getShRows(){{
6093 var proj=shProjFilter.toLowerCase().trim();
6094 var branch=shBranchFilter;
6095 return shData.filter(function(d){{
6096 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6097 if(branch&&(d.branch||'')!==branch)return false;
6098 return true;
6099 }});
6100 }}
6101
6102 function renderShPage(){{
6103 var filtered=getShRows();
6104 if(shSortCol){{
6105 filtered.sort(function(a,b){{
6106 var va,vb;
6107 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6108 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6109 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6110 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6111 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6112 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6113 }});
6114 }}
6115 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6116 shPage=Math.min(shPage,totalPages);
6117 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6118 var visible=filtered.slice(start,end);
6119 var tbody=document.getElementById('sh-tbody');
6120 if(!tbody)return;
6121 tbody.innerHTML=visible.map(function(d){{
6122 var tsHtml=esc(fmtPST(d.timestamp));
6123 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>';
6124 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>';
6125 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
6126 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
6127 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6128 var reportCell='';
6129 if(d.html_url){{
6130 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6131 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>';}}
6132 reportCell+='</div>';
6133 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
6134 if(d.submodule_links&&d.submodule_links.length){{
6135 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6136 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6137 reportCell+='</div></details>';
6138 }}
6139 return '<tr>'
6140 +'<td>'+tsHtml+'</td>'
6141 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6142 +'<td>'+runIdHtml+'</td>'
6143 +'<td>'+commitHtml+'</td>'
6144 +'<td>'+branchHtml+'</td>'
6145 +'<td>'+tags+'</td>'
6146 +'<td class="num">'+metricHtml+'</td>'
6147 +'<td class="report-cell">'+reportCell+'</td>'
6148 +'</tr>';
6149 }}).join('');
6150 var pgRange=document.getElementById('sh-pg-range');
6151 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6152 var pgInfo=document.getElementById('sh-pg-info');
6153 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6154 var pgBtns=document.getElementById('sh-pg-btns');
6155 if(pgBtns){{
6156 pgBtns.innerHTML='';
6157 function mkPgBtn(lbl,pg,active,disabled){{
6158 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6159 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6160 return b;
6161 }}
6162 pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6163 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6164 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6165 pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6166 }}
6167 }}
6168
6169 function wireTableBehavior(){{
6170 var pf=document.getElementById('sh-proj-filter');
6171 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6172 var bf=document.getElementById('sh-branch-filter');
6173 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6174 var rb=document.getElementById('sh-reset-btn');
6175 if(rb)rb.addEventListener('click',function(){{
6176 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6177 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6178 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6179 document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6180 renderShPage();
6181 }});
6182 var pps=document.getElementById('sh-per-page');
6183 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6184 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6185 ths.forEach(function(th){{
6186 th.addEventListener('click',function(e){{
6187 if(e.target.classList.contains('col-resize-handle'))return;
6188 var col=th.dataset.col;
6189 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6190 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6191 th.classList.add('sort-'+shSortOrder);
6192 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6193 shPage=1;renderShPage();
6194 }});
6195 }});
6196 var table=document.getElementById('scan-history-table');
6197 if(!table)return;
6198 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6199 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6200 allThs.forEach(function(th,i){{
6201 var handle=th.querySelector('.col-resize-handle');
6202 if(!handle||!cols[i])return;
6203 var startX,startW;
6204 handle.addEventListener('mousedown',function(e){{
6205 e.stopPropagation();e.preventDefault();
6206 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6207 handle.classList.add('dragging');
6208 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6209 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6210 document.addEventListener('mousemove',onMove);
6211 document.addEventListener('mouseup',onUp);
6212 }});
6213 }});
6214 }}
6215
6216 function renderTable(pts, yKey){{
6217 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6218 var wrap=document.getElementById('data-table-wrap');
6219 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6220 var yLabel=Y_LABELS[yKey]||yKey||'';
6221 shData=pts.slice().reverse();
6222 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6223 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6224 var branches={{}};
6225 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6226 var branchOpts='<option value="">All branches</option>';
6227 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6228 wrap.innerHTML=
6229 '<div class="chart-section-header">SCAN HISTORY</div>'+
6230 '<div class="filter-row">'+
6231 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6232 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6233 '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6234 '</div>'+
6235 '<div class="table-wrap">'+
6236 '<table id="scan-history-table" class="data-table">'+
6237 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6238 '<thead><tr id="sh-thead">'+
6239 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6240 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6241 '<th>Run ID<div class="col-resize-handle"></div></th>'+
6242 '<th>Commit<div class="col-resize-handle"></div></th>'+
6243 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6244 '<th>Tags<div class="col-resize-handle"></div></th>'+
6245 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6246 '<th>Report<div class="col-resize-handle"></div></th>'+
6247 '</tr></thead>'+
6248 '<tbody id="sh-tbody"></tbody>'+
6249 '</table>'+
6250 '</div>'+
6251 '<div class="pagination">'+
6252 '<span class="pagination-info" id="sh-pg-info"></span>'+
6253 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6254 '<div style="display:flex;align-items:center;gap:8px;">'+
6255 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6256 '<select class="filter-select" id="sh-per-page">'+
6257 '<option value="10">10 per page</option>'+
6258 '<option value="25" selected>25 per page</option>'+
6259 '<option value="50">50 per page</option>'+
6260 '<option value="100">100 per page</option>'+
6261 '</select>'+
6262 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6263 '</div>'+
6264 '</div>';
6265 wireTableBehavior();
6266 renderShPage();
6267 }}
6268
6269 function exportXLSX(){{
6270 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6271 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6272 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6273 var s1R=sorted.map(function(d){{
6274 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||''];
6275 }});
6276 var pm={{}};
6277 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6278 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'];
6279 var s2R=Object.keys(pm).map(function(p){{
6280 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6281 var lat=sc[sc.length-1],fst=sc[0];
6282 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6283 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);
6284 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];
6285 }});
6286 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6287 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6288 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6289 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6290 }}
6291
6292 function buildXLSX(sheets,chartRows,chartRows2){{
6293 function s2b(s){{return new TextEncoder().encode(s);}}
6294 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
6295 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;}}
6296 function crc32(d){{
6297 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;}}}}
6298 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6299 }}
6300 function buildSheet(hdr,rows,drawRid,withCtrl){{
6301 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6302 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6303 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6304 x+='<row r="1">';
6305 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6306 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>';}}
6307 x+='</row>';
6308 rows.forEach(function(row,ri){{
6309 var rn=ri+2;
6310 x+='<row r="'+rn+'">';
6311 row.forEach(function(cell,ci){{
6312 var addr=col2l(ci+1)+rn;
6313 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6314 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6315 }});
6316 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>';}}
6317 x+='</row>';
6318 }});
6319 x+='</sheetData>';
6320 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>';}}
6321 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6322 return x+'</worksheet>';
6323 }}
6324 function buildChartXML(rows){{
6325 var sn="'Scan History'";
6326 var nr=rows.length,er=nr+1;
6327 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'}}];
6328 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6329 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">';
6330 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6331 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6332 sd.forEach(function(s,i){{
6333 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6334 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>';
6335 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6336 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>';
6337 var dlp=(i===2)?'b':'t';
6338 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>';
6339 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6340 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6341 x+='</c:strCache></c:strRef></c:cat>';
6342 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+'"/>';
6343 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6344 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6345 }});
6346 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6347 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>';
6348 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>';
6349 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6350 return x;
6351 }}
6352 function buildChartXML2(rows){{
6353 var sn="'By Project'";
6354 var nr=rows.length,er=nr+1;
6355 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'}}];
6356 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6357 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">';
6358 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6359 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6360 sd.forEach(function(s,i){{
6361 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6362 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>';
6363 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6364 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>';
6365 var dlp=(i===2)?'b':'t';
6366 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>';
6367 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6368 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6369 x+='</c:strCache></c:strRef></c:cat>';
6370 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+'"/>';
6371 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6372 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6373 }});
6374 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6375 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>';
6376 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>';
6377 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6378 return x;
6379 }}
6380 function buildChartXML3(rows){{
6381 var sn="'Scan History'";
6382 var nr=rows.length,er=nr+1;
6383 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6384 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">';
6385 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6386 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6387 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6388 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>';
6389 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6390 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>';
6391 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>';
6392 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6393 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6394 x+='</c:strCache></c:strRef></c:cat>';
6395 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+'"/>';
6396 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6397 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6398 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6399 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>';
6400 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>';
6401 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>';
6402 return x;
6403 }}
6404 var hasChart=!!(chartRows&&chartRows.length);
6405 var nr=hasChart?chartRows.length:0;
6406 var hasChart2=!!(chartRows2&&chartRows2.length);
6407 var nr2=hasChart2?chartRows2.length:0;
6408 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>';
6409 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"/>';
6410 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6411 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"/>';}}
6412 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"/>';}}
6413 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6414 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>';
6415 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6416 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"/>';}});
6417 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6418 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>';
6419 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6420 wbx+='</sheets></workbook>';
6421 var files=[
6422 {{name:'[Content_Types].xml',data:s2b(ct)}},
6423 {{name:'_rels/.rels',data:s2b(dotrels)}},
6424 {{name:'xl/workbook.xml',data:s2b(wbx)}},
6425 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6426 {{name:'xl/styles.xml',data:s2b(styl)}}
6427 ];
6428 // Chart embedded directly in Scan History (sheet1); By Project is plain
6429 sheets.forEach(function(s,i){{
6430 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)))}});
6431 }});
6432 if(hasChart){{
6433 var fromRow=nr+4,toRow=nr+24;
6434 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>')}});
6435 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6436 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">';
6437 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6438 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>';
6439 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>';
6440 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6441 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6442 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6443 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6444 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6445 var focRow=toRow+2,focRowEnd=toRow+22;
6446 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6447 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>';
6448 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>';
6449 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6450 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6451 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6452 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6453 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6454 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6455 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>')}});
6456 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6457 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6458 }}
6459 if(hasChart2){{
6460 var fromRow2=nr2+4,toRow2=nr2+24;
6461 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>')}});
6462 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6463 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">';
6464 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6465 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>';
6466 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>';
6467 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6468 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6469 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6470 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6471 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6472 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6473 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>')}});
6474 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6475 }}
6476 var parts=[],offsets=[],total=0;
6477 files.forEach(function(f){{
6478 offsets.push(total);
6479 var nb=s2b(f.name),crc=crc32(f.data);
6480 var h=new DataView(new ArrayBuffer(30+nb.length));
6481 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6482 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6483 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6484 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6485 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6486 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6487 total+=30+nb.length+f.data.length;
6488 }});
6489 var cdStart=total;
6490 files.forEach(function(f,fi){{
6491 var nb=s2b(f.name),crc=crc32(f.data);
6492 var cd=new DataView(new ArrayBuffer(46+nb.length));
6493 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6494 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6495 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6496 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6497 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6498 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6499 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6500 }});
6501 var cdSz=total-cdStart;
6502 var eocd=new DataView(new ArrayBuffer(22));
6503 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6504 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6505 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6506 parts.push(new Uint8Array(eocd.buffer));
6507 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6508 var out=new Uint8Array(sz);var off=0;
6509 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6510 return out.buffer;
6511 }}
6512
6513 function exportPNG(){{
6514 var svgEl=document.querySelector('#chart-wrap svg');
6515 if(!svgEl){{alert('No chart to export yet.');return;}}
6516 var svgStr=new XMLSerializer().serializeToString(svgEl);
6517 var vb=svgEl.viewBox.baseVal,scale=2;
6518 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6519 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6520 var url=URL.createObjectURL(blob);
6521 var img=new Image();
6522 img.onload=function(){{
6523 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6524 var ctx=canvas.getContext('2d');
6525 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6526 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6527 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6528 URL.revokeObjectURL(url);
6529 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6530 }};
6531 img.src=url;
6532 }}
6533
6534 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6535 var el=document.getElementById(id);
6536 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6537 }});
6538 rootSel.addEventListener('change',function(){{
6539 populateSubmodules(rootSel.value);
6540 loadAndRender();
6541 }});
6542 if(subSel)subSel.addEventListener('change',loadAndRender);
6543
6544 var xlsxBtn=document.getElementById('export-xlsx-btn');
6545 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6546 var pngBtn=document.getElementById('export-png-btn');
6547 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6548
6549 populateSubmodules(rootSel.value);
6550 loadAndRender();
6551
6552 (function randomizeWatermarks() {{
6553 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6554 if (!wms.length) return;
6555 var placed = [];
6556 function tooClose(top, left) {{
6557 for (var i = 0; i < placed.length; i++) {{
6558 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6559 if (dt < 16 && dl < 12) return true;
6560 }}
6561 return false;
6562 }}
6563 function pick(leftBand) {{
6564 for (var attempt = 0; attempt < 50; attempt++) {{
6565 var top = Math.random() * 88 + 2;
6566 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6567 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6568 }}
6569 var top = Math.random() * 88 + 2;
6570 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6571 placed.push([top, left]); return [top, left];
6572 }}
6573 var half = Math.floor(wms.length / 2);
6574 wms.forEach(function (img, i) {{
6575 var pos = pick(i < half);
6576 var size = Math.floor(Math.random() * 100 + 120);
6577 var rot = (Math.random() * 360).toFixed(1);
6578 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6579 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;
6580 }});
6581 }})();
6582 (function spawnCodeParticles() {{
6583 var container = document.getElementById('code-particles');
6584 if (!container) return;
6585 var snippets = [
6586 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6587 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6588 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6589 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6590 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6591 ];
6592 var count = 38;
6593 for (var i = 0; i < count; i++) {{
6594 (function(idx) {{
6595 var el = document.createElement('span');
6596 el.className = 'code-particle';
6597 el.textContent = snippets[idx % snippets.length];
6598 var left = Math.random() * 94 + 2;
6599 var top = Math.random() * 88 + 6;
6600 var dur = (Math.random() * 10 + 9).toFixed(1);
6601 var delay = (Math.random() * 18).toFixed(1);
6602 var rot = (Math.random() * 26 - 13).toFixed(1);
6603 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6604 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6605 container.appendChild(el);
6606 }})(i);
6607 }}
6608 }})();
6609 </script>
6610 <footer class="site-footer">
6611 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
6612 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6613 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6614 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6615 · <a href="/api-docs" rel="noopener">REST API</a>
6616 </footer>
6617</body>
6618</html>"##,
6619 );
6620
6621 Html(html).into_response()
6622}
6623
6624fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
6625 use std::collections::HashMap;
6626 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
6627 return vec![];
6628 }
6629 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6630 for rec in per_file_records {
6631 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6632 let e = totals.entry(lang.display_name().to_string()).or_default();
6633 e.0 += u64::from(cov.lines_found);
6634 e.1 += u64::from(cov.lines_hit);
6635 }
6636 }
6637 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
6639 .into_iter()
6640 .filter(|(_, (found, _))| *found > 0)
6641 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6642 .collect();
6643 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6644 pairs
6645 .iter()
6646 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
6647 .collect()
6648}
6649
6650fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
6651 let mut high = 0u64;
6652 let mut mid = 0u64;
6653 let mut low = 0u64;
6654 for rec in per_file_records {
6655 if let Some(cov) = &rec.coverage {
6656 if cov.lines_found == 0 {
6657 continue;
6658 }
6659 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6660 if pct >= 80.0 {
6661 high += 1;
6662 } else if pct >= 50.0 {
6663 mid += 1;
6664 } else {
6665 low += 1;
6666 }
6667 }
6668 }
6669 (high, mid, low)
6670}
6671
6672fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
6673 let mut arr: Vec<serde_json::Value> = per_file_records
6674 .iter()
6675 .filter_map(|rec| {
6676 rec.coverage.as_ref().map(|cov| {
6677 let line_pct = if cov.lines_found > 0 {
6678 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6679 / 10.0
6680 } else {
6681 0.0
6682 };
6683 let fn_pct = if cov.functions_found > 0 {
6684 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6685 .round()
6686 / 10.0
6687 } else {
6688 -1.0
6689 };
6690 serde_json::json!({
6691 "rel": rec.relative_path,
6692 "lang": rec.language.map_or("?", |l| l.display_name()),
6693 "line_pct": line_pct,
6694 "fn_pct": fn_pct,
6695 "lhit": cov.lines_hit,
6696 "lfound": cov.lines_found,
6697 "fhit": cov.functions_hit,
6698 "ffound": cov.functions_found,
6699 })
6700 })
6701 })
6702 .collect();
6703 arr.sort_by(|a, b| {
6704 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
6705 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
6706 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
6707 });
6708 arr
6709}
6710
6711#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6713 let mut langs: Vec<&sloc_core::LanguageSummary> = run
6714 .totals_by_language
6715 .iter()
6716 .filter(|l| l.test_count > 0)
6717 .collect();
6718 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6719 let lang_tests: Vec<serde_json::Value> = langs
6720 .iter()
6721 .map(|l| {
6722 let d = if l.code_lines > 0 {
6723 l.test_count as f64 / l.code_lines as f64 * 1000.0
6724 } else {
6725 0.0
6726 };
6727 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6728 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6729 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6730 })
6731 .collect();
6732 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
6733 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
6734 let t = &run.summary_totals;
6735 let total_tests = t.test_count;
6736 let density = if t.code_lines > 0 {
6737 total_tests as f64 / t.code_lines as f64 * 1000.0
6738 } else {
6739 0.0
6740 };
6741 let most_tested = langs.first().map_or_else(
6742 || "\u{2014}".to_string(),
6743 |l| l.language.display_name().to_string(),
6744 );
6745 let test_files: u64 = run
6746 .per_file_records
6747 .iter()
6748 .filter(|f| f.raw_line_categories.test_count > 0)
6749 .count() as u64;
6750 let cov_line = if t.coverage_lines_found > 0 {
6751 format!(
6752 "{:.1}",
6753 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6754 )
6755 } else {
6756 "0".to_string()
6757 };
6758 let cov_fn = if t.coverage_functions_found > 0 {
6759 format!(
6760 "{:.1}",
6761 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6762 )
6763 } else {
6764 "0".to_string()
6765 };
6766 let cov_branch = if t.coverage_branches_found > 0 {
6767 format!(
6768 "{:.1}",
6769 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6770 )
6771 } else {
6772 "0".to_string()
6773 };
6774 let has_cov = !cov_arr.is_empty();
6775 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
6776 serde_json::json!({
6777 "totals": {
6778 "test_count": total_tests,
6779 "assertions": t.test_assertion_count,
6780 "suites": t.test_suite_count,
6781 "test_files": test_files,
6782 "total_files": t.files_analyzed,
6783 "density_str": format!("{density:.1}"),
6784 "most_tested": most_tested,
6785 "langs_with_tests": langs.len(),
6786 "cov_line": cov_line,
6787 "cov_fn": cov_fn,
6788 "cov_branch": cov_branch,
6789 },
6790 "lang_tests": lang_tests,
6791 "cov": cov_arr,
6792 "cov_tiers": {"high": high, "mid": mid, "low": low},
6793 "file_cov": file_cov_arr,
6794 "has_coverage": has_cov,
6795 "submodules": {},
6796 })
6797}
6798
6799#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
6801 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
6802 .language_summaries
6803 .iter()
6804 .filter(|l| l.test_count > 0)
6805 .collect();
6806 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6807 let lang_tests: Vec<serde_json::Value> = langs
6808 .iter()
6809 .map(|l| {
6810 let d = if l.code_lines > 0 {
6811 l.test_count as f64 / l.code_lines as f64 * 1000.0
6812 } else {
6813 0.0
6814 };
6815 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6816 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6817 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6818 })
6819 .collect();
6820 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
6821 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
6822 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
6823 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
6824 let density = if sub.code_lines > 0 {
6825 total_tests as f64 / sub.code_lines as f64 * 1000.0
6826 } else {
6827 0.0
6828 };
6829 let most_tested = langs.first().map_or_else(
6830 || "\u{2014}".to_string(),
6831 |l| l.language.display_name().to_string(),
6832 );
6833 serde_json::json!({
6834 "totals": {
6835 "test_count": total_tests,
6836 "assertions": total_assertions,
6837 "suites": total_suites,
6838 "test_files": test_files_approx,
6839 "total_files": sub.files_analyzed,
6840 "density_str": format!("{density:.1}"),
6841 "most_tested": most_tested,
6842 "langs_with_tests": langs.len(),
6843 "cov_line": "0",
6844 "cov_fn": "0",
6845 "cov_branch": "0",
6846 },
6847 "lang_tests": lang_tests,
6848 "cov": [],
6849 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
6850 "has_coverage": false,
6851 })
6852}
6853
6854fn compute_cov_json_str(run: &AnalysisRun) -> String {
6855 use std::collections::HashMap;
6856 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6857 for rec in &run.per_file_records {
6858 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6859 let e = totals.entry(lang.display_name().to_string()).or_default();
6860 e.0 += u64::from(cov.lines_found);
6861 e.1 += u64::from(cov.lines_hit);
6862 }
6863 }
6864 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
6866 .into_iter()
6867 .filter(|(_, (found, _))| *found > 0)
6868 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6869 .collect();
6870 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6871 let parts: Vec<String> = pairs
6872 .iter()
6873 .map(|(lang, pct)| {
6874 let name = lang.replace('"', "\\\"");
6875 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
6876 })
6877 .collect();
6878 format!("[{}]", parts.join(","))
6879}
6880
6881fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
6882 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
6883 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
6884}
6885
6886fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
6887 let mut entry = build_test_scope_entry(run);
6888 if !run.submodule_summaries.is_empty() {
6889 let subs: serde_json::Map<String, serde_json::Value> = run
6890 .submodule_summaries
6891 .iter()
6892 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
6893 .collect();
6894 entry["submodules"] = serde_json::Value::Object(subs);
6895 }
6896 entry
6897}
6898
6899#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
6903 State(state): State<AppState>,
6904 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6905) -> Response {
6906 auto_scan_watched_dirs(&state).await;
6907 let watched_dirs_list: Vec<String> = {
6908 let wd = state.watched_dirs.lock().await;
6909 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6910 };
6911 let latest_run: Option<AnalysisRun> = {
6912 let reg = state.registry.lock().await;
6913 let json_str: Option<String> = reg
6914 .entries
6915 .first()
6916 .and_then(|e| e.json_path.as_ref())
6917 .and_then(|p| std::fs::read_to_string(p).ok());
6918 drop(reg);
6919 json_str
6920 .as_deref()
6921 .and_then(|s| serde_json::from_str(s).ok())
6922 };
6923
6924 let _lang_tests_json: String = latest_run.as_ref().map_or_else(
6926 || "[]".to_string(),
6927 |r| {
6928 let mut langs: Vec<&sloc_core::LanguageSummary> = r
6929 .totals_by_language
6930 .iter()
6931 .filter(|l| l.test_count > 0)
6932 .collect();
6933 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6934 let parts: Vec<String> = langs
6935 .iter()
6936 .map(|l| {
6937 let name = l.language.display_name().replace('"', "\\\"");
6938 let density = if l.code_lines > 0 {
6939 #[allow(clippy::cast_precision_loss)]
6941 { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
6942 } else {
6943 0.0
6944 };
6945 format!(
6946 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
6947 name = name,
6948 t = l.test_count,
6949 a = l.test_assertion_count,
6950 s = l.test_suite_count,
6951 c = l.code_lines,
6952 d = density,
6953 f = l.files,
6954 )
6955 })
6956 .collect();
6957 format!("[{}]", parts.join(","))
6958 },
6959 );
6960
6961 let cov_json: String = latest_run
6963 .as_ref()
6964 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
6965 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
6966
6967 let _cov_tier_json: String = latest_run
6969 .as_ref()
6970 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
6971 .map_or_else(
6972 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
6973 compute_cov_tier_json_str,
6974 );
6975
6976 let total_tests: u64 = latest_run
6977 .as_ref()
6978 .map_or(0, |r| r.summary_totals.test_count);
6979 let total_assertions: u64 = latest_run
6980 .as_ref()
6981 .map_or(0, |r| r.summary_totals.test_assertion_count);
6982 let total_suites: u64 = latest_run
6983 .as_ref()
6984 .map_or(0, |r| r.summary_totals.test_suite_count);
6985 let total_code: u64 = latest_run
6986 .as_ref()
6987 .map_or(0, |r| r.summary_totals.code_lines);
6988 let workspace_density: f64 = if total_code > 0 {
6989 total_tests as f64 / total_code as f64 * 1000.0
6990 } else {
6991 0.0
6992 };
6993 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
6994 r.totals_by_language
6995 .iter()
6996 .filter(|l| l.test_count > 0)
6997 .count()
6998 });
6999 let most_tested: String = latest_run
7000 .as_ref()
7001 .and_then(|r| {
7002 r.totals_by_language
7003 .iter()
7004 .filter(|l| l.test_count > 0)
7005 .max_by_key(|l| l.test_count)
7006 })
7007 .map_or_else(
7008 || "\u{2014}".to_string(),
7009 |l| l.language.display_name().to_string(),
7010 );
7011 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7012 r.per_file_records
7013 .iter()
7014 .filter(|f| f.raw_line_categories.test_count > 0)
7015 .count() as u64
7016 });
7017 let total_files_analyzed: u64 = latest_run
7018 .as_ref()
7019 .map_or(0, |r| r.summary_totals.files_analyzed);
7020 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7021
7022 let cov_line_pct_str: String = latest_run
7024 .as_ref()
7025 .filter(|r| r.summary_totals.coverage_lines_found > 0)
7026 .map_or_else(
7027 || "0".to_string(),
7028 |r| {
7029 format!(
7030 "{:.1}",
7031 r.summary_totals.coverage_lines_hit as f64
7032 / r.summary_totals.coverage_lines_found as f64
7033 * 100.0
7034 )
7035 },
7036 );
7037 let cov_fn_pct_str: String = latest_run
7038 .as_ref()
7039 .filter(|r| r.summary_totals.coverage_functions_found > 0)
7040 .map_or_else(
7041 || "0".to_string(),
7042 |r| {
7043 format!(
7044 "{:.1}",
7045 r.summary_totals.coverage_functions_hit as f64
7046 / r.summary_totals.coverage_functions_found as f64
7047 * 100.0
7048 )
7049 },
7050 );
7051 let cov_branch_pct_str: String = latest_run
7052 .as_ref()
7053 .filter(|r| r.summary_totals.coverage_branches_found > 0)
7054 .map_or_else(
7055 || "0".to_string(),
7056 |r| {
7057 format!(
7058 "{:.1}",
7059 r.summary_totals.coverage_branches_hit as f64
7060 / r.summary_totals.coverage_branches_found as f64
7061 * 100.0
7062 )
7063 },
7064 );
7065
7066 let cov_no_data_notice = if has_coverage {
7067 String::new()
7068 } else {
7069 String::from(
7070 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7071<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>
7072<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7073 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7074 <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>
7075 <span style="color:var(--muted);font-size:12px;">·</span>
7076 <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>
7077 <span style="color:var(--muted);font-size:12px;">·</span>
7078 <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>
7079</div>
7080<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7081</div>"#,
7082 )
7083 };
7084
7085 let workspace_density_str = format!("{workspace_density:.1}");
7086 let nonce = &csp_nonce;
7087 let version = env!("CARGO_PKG_VERSION");
7088
7089 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7090 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7091 .to_string()
7092 } else {
7093 watched_dirs_list
7094 .iter()
7095 .fold(String::new(), |mut s, d| {
7096 use std::fmt::Write as _;
7097 let escaped =
7098 d.replace('&', "&").replace('"', """).replace('<', "<");
7099 write!(
7100 s,
7101 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>"#
7102 ).expect("write to String is infallible");
7103 s
7104 })
7105 };
7106 let watched_dirs_html = format!(
7107 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>"#
7108 );
7109
7110 let scope_data_json: String = {
7112 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7113 scope_map.insert(
7114 "__all__".to_string(),
7115 latest_run.as_ref().map_or_else(
7116 || {
7117 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7118 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7119 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7120 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7121 "has_coverage":false,"submodules":{}})
7122 },
7123 build_test_scope_entry,
7124 ),
7125 );
7126 let all_roots: Vec<String> = {
7127 let reg = state.registry.lock().await;
7128 let mut seen = std::collections::BTreeSet::new();
7129 reg.entries
7130 .iter()
7131 .flat_map(|e| e.input_roots.iter().cloned())
7132 .filter(|r| seen.insert(r.clone()))
7133 .collect()
7134 };
7135 for root in &all_roots {
7136 let run_for_root: Option<AnalysisRun> = {
7137 let reg = state.registry.lock().await;
7138 let json_str = reg
7139 .entries
7140 .iter()
7141 .find(|e| e.input_roots.iter().any(|r| r == root))
7142 .and_then(|e| e.json_path.as_ref())
7143 .and_then(|p| std::fs::read_to_string(p).ok());
7144 drop(reg);
7145 json_str
7146 .as_deref()
7147 .and_then(|s| serde_json::from_str(s).ok())
7148 };
7149 if let Some(ref run) = run_for_root {
7150 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
7151 }
7152 }
7153 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7154 };
7155
7156 let html = format!(
7157 r#"<!doctype html>
7158<html lang="en">
7159<head>
7160 <meta charset="utf-8" />
7161 <meta name="viewport" content="width=device-width, initial-scale=1" />
7162 <title>OxideSLOC | Test Metrics</title>
7163 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7164 <style nonce="{nonce}">
7165 :root {{
7166 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7167 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7168 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7169 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7170 --info-bg:#eef3ff; --info-text:#4467d8;
7171 }}
7172 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7173 *{{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);}}
7174 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7175 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7176 .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;}}
7177 @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));}}}}
7178 .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);}}
7179 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7180 .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));}}
7181 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7182 .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;}}
7183 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7184 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7185 @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; }} }}
7186 .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;}}
7187 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7188 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7189 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7190 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7191 .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;}}
7192 .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;}}
7193 .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;}}
7194 .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;}}
7195 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7196 .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);}}
7197 .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;}}
7198 .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;}}
7199 .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;}}
7200 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7201 .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;}}
7202 .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);}}
7203 .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;}}
7204 .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;}}
7205 .tz-select:focus{{border-color:var(--oxide);}}
7206 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7207 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7208 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7209 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7210 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7211 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7212 .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;}}
7213 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7214 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7215 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7216 .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;}}
7217 .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;}}
7218 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7219 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7220 .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);}}
7221 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7222 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7223 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7224 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7225 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7226 .chart-canvas-wrap{{position:relative;height:280px;}}
7227 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7228 .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;}}
7229 .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;}}
7230 .data-table tr:last-child td{{border-bottom:none;}}
7231 .data-table tbody tr:hover td{{background:var(--surface-2);}}
7232 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7233 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7234 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7235 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7236 .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;}}
7237 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7238 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7239 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7240 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7241 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7242 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7243 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7244 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7245 .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;}}
7246 .chart-select:focus{{border-color:var(--accent);}}
7247 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7248 .trend-canvas-wrap{{position:relative;height:260px;}}
7249 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7250 .site-footer a{{color:var(--muted);}}
7251 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7252 .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;}}
7253 .btn:hover{{background:var(--surface-2);}}
7254 .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;margin-bottom:16px;position:relative;z-index:1;flex-wrap:wrap;}}
7255 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7256 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7257 .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;}}
7258 .scope-sel:focus{{border-color:var(--accent);}}
7259 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7260 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;flex-wrap:wrap;margin-bottom:16px;position:relative;z-index:1;}}
7261 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7262 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7263 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7264 .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;}}
7265 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7266 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7267 .watched-chip-rm:hover{{color:var(--oxide);}}
7268 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7269 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7270 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7271 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7272 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7273 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7274 .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;}}
7275 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7276 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7277 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7278 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7279 .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;}}
7280 .cov-file-search:focus{{border-color:var(--accent);}}
7281 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7282 .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;}}
7283 body.dark-theme .cov-file-search{{background:var(--surface);}}
7284 </style>
7285</head>
7286<body>
7287 <div class="background-watermarks" aria-hidden="true">
7288 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7289 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7290 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7291 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7292 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7293 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7294 </div>
7295 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7296 <div class="top-nav">
7297 <div class="top-nav-inner">
7298 <a class="brand" href="/">
7299 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7300 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7301 </a>
7302 <div class="nav-right">
7303 <a class="nav-pill" href="/">Home</a>
7304 <div class="nav-dropdown">
7305 <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>
7306 <div class="nav-dropdown-menu">
7307 <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>
7308 </div>
7309 </div>
7310 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7311 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7312 <div class="nav-dropdown">
7313 <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>
7314 <div class="nav-dropdown-menu">
7315 <a href="/webhook-setup"><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>
7316 </div>
7317 </div>
7318 <div class="server-status-wrap">
7319 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7320 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
7321 </div>
7322 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7323 <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>
7324 </button>
7325 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7326 <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>
7327 <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>
7328 </button>
7329 </div>
7330 </div>
7331 </div>
7332
7333 <div class="page">
7334 {watched_dirs_html}
7335 <div class="scope-bar">
7336 <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>
7337 <span class="scope-label">Scope</span>
7338 <div class="scope-sel-wrap">
7339 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7340 <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);">
7341 <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>
7342 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7343 </div>
7344 </div>
7345 </div>
7346 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7347 <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>
7348 <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>
7349 <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>
7350 <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>
7351 </div>
7352 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7353 <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>
7354 <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>
7355 <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>
7356 <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>
7357 </div>
7358
7359 <div class="panel">
7360 <h1>Test Metrics</h1>
7361 <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>
7362
7363 <div class="chart-row">
7364 <div class="chart-box">
7365 <div class="chart-box-title">Test Definitions by Language</div>
7366 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7367 </div>
7368 <div class="chart-box">
7369 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7370 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7371 </div>
7372 </div>
7373
7374 <div class="section-header">Language Breakdown</div>
7375 {cov_no_data_notice}
7376 <div style="overflow-x:auto;">
7377 <table class="data-table" id="lang-table">
7378 <thead><tr>
7379 <th>Language</th>
7380 <th class="num">Test Fns</th>
7381 <th class="num">Assertions</th>
7382 <th class="num">Suites</th>
7383 <th class="num">Code Lines</th>
7384 <th class="num">Files</th>
7385 <th class="num">Density / 1K</th>
7386 <th>Relative Density</th>
7387 </tr></thead>
7388 <tbody id="lang-tbody"></tbody>
7389 </table>
7390 </div>
7391 </div>
7392
7393 <div class="panel" id="cov-panel" style="display:none;">
7394 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7395 <div class="cov-gauge-row" id="cov-gauges">
7396 <div class="cov-gauge-card">
7397 <div class="cov-gauge-label">Line Coverage</div>
7398 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7399 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7400 <div class="cov-gauge-sub">Lines hit / instrumented</div>
7401 </div>
7402 <div class="cov-gauge-card">
7403 <div class="cov-gauge-label">Function Coverage</div>
7404 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7405 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7406 <div class="cov-gauge-sub">Functions hit / found</div>
7407 </div>
7408 <div class="cov-gauge-card">
7409 <div class="cov-gauge-label">Branch Coverage</div>
7410 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7411 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7412 <div class="cov-gauge-sub">Branches hit / found</div>
7413 </div>
7414 </div>
7415 <div class="chart-row">
7416 <div class="chart-box">
7417 <div class="chart-box-title">Line Coverage % by Language</div>
7418 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7419 </div>
7420 <div class="chart-box">
7421 <div class="chart-box-title">Coverage Tier Distribution</div>
7422 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7423 </div>
7424 </div>
7425
7426 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7427 <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>
7428 <div class="cov-file-toolbar">
7429 <div class="cov-filter-tabs" id="cov-filter-tabs">
7430 <button class="cov-tab active" data-tier="all">All</button>
7431 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7432 <button class="cov-tab" data-tier="low">Low (<50%)</button>
7433 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7434 <button class="cov-tab" data-tier="high">High (≥80%)</button>
7435 </div>
7436 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7437 </div>
7438 <div style="overflow-x:auto;">
7439 <table class="data-table" id="cov-file-table">
7440 <thead><tr>
7441 <th>File</th>
7442 <th>Lang</th>
7443 <th class="num">Line %</th>
7444 <th class="num">Lines Hit / Found</th>
7445 <th class="num">Fn %</th>
7446 <th class="num">Fns Hit / Found</th>
7447 </tr></thead>
7448 <tbody id="cov-file-tbody"></tbody>
7449 </table>
7450 </div>
7451 <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>
7452 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7453 </div>
7454
7455 <div class="panel">
7456 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7457 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7458 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7459 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7460 </div>
7461 </div>
7462
7463 <footer class="site-footer">
7464 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
7465 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7466 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7467 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7468 · <a href="/api-docs" rel="noopener">REST API</a>
7469 </footer>
7470
7471 <script nonce="{nonce}">
7472 (function() {{
7473 // Theme
7474 var b = document.body;
7475 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7476 var tgl = document.getElementById('theme-toggle');
7477 if (tgl) tgl.addEventListener('click', function() {{
7478 var d = b.classList.toggle('dark-theme');
7479 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7480 }});
7481
7482 // Watermarks
7483 (function() {{
7484 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7485 if (!wms.length) return;
7486 var placed = [];
7487 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;}}
7488 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];}}
7489 var half=Math.floor(wms.length/2);
7490 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;}});
7491 }})();
7492
7493 // Code particles
7494 (function() {{
7495 var container = document.getElementById('code-particles');
7496 if (!container) return;
7497 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7498 for (var i = 0; i < 36; i++) {{
7499 (function(idx) {{
7500 var el = document.createElement('span');
7501 el.className = 'code-particle';
7502 el.textContent = snippets[idx % snippets.length];
7503 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7504 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7505 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7506 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';
7507 container.appendChild(el);
7508 }})(i);
7509 }}
7510 }})();
7511
7512 // Settings modal
7513 (function() {{
7514 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'}}];
7515 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);}});}}
7516 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7517 var btn=document.getElementById('settings-btn');if(!btn)return;
7518 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7519 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>';
7520 document.body.appendChild(m);
7521 var g=document.getElementById('scheme-grid');
7522 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);}});
7523 var cl=document.getElementById('settings-close');
7524 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');}});
7525 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7526 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7527 }})();
7528
7529 // Watched folder picker
7530 (function() {{
7531 var btn = document.getElementById('add-watched-btn');
7532 if (!btn) return;
7533 btn.addEventListener('click', function() {{
7534 fetch('/pick-directory?kind=reports')
7535 .then(function(r) {{ return r.json(); }})
7536 .then(function(data) {{
7537 if (!data.cancelled && data.selected_path) {{
7538 var form = document.createElement('form');
7539 form.method = 'POST';
7540 form.action = '/watched-dirs/add';
7541 var ri = document.createElement('input');
7542 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7543 var fi = document.createElement('input');
7544 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7545 form.appendChild(ri); form.appendChild(fi);
7546 document.body.appendChild(form);
7547 form.submit();
7548 }}
7549 }})
7550 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7551 }});
7552 }})();
7553 }})();
7554 </script>
7555
7556 <script src="/static/chart.js" nonce="{nonce}"></script>
7557 <script nonce="{nonce}">
7558 (function() {{
7559 var SCOPE_DATA = {scope_data_json};
7560 var currentRoot = '__all__';
7561 var currentSub = '';
7562 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7563 var ALL_CHARTS = [];
7564
7565 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();}}
7566 function fmtFull(n){{return Number(n).toLocaleString();}}
7567 function isDark(){{return document.body.classList.contains('dark-theme');}}
7568 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7569 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7570 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7571
7572 function getDataset() {{
7573 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7574 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7575 return r;
7576 }}
7577 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7578
7579 function renderTestCharts(D) {{
7580 testsChart = destroyChart(testsChart);
7581 densityChart = destroyChart(densityChart);
7582 if (!D || !D.length) return;
7583 var top15 = D.slice(0, 15);
7584 var canvas1 = document.getElementById('canvas-tests');
7585 if (canvas1) {{
7586 testsChart = new Chart(canvas1, {{
7587 type: 'bar',
7588 data: {{
7589 labels: top15.map(function(d){{ return d.lang; }}),
7590 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7591 }},
7592 options: {{
7593 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7594 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7595 scales: {{
7596 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7597 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7598 }}
7599 }}
7600 }});
7601 ALL_CHARTS.push(testsChart);
7602 }}
7603 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7604 var canvas2 = document.getElementById('canvas-density');
7605 if (canvas2) {{
7606 densityChart = new Chart(canvas2, {{
7607 type: 'bar',
7608 data: {{
7609 labels: topD.map(function(d){{ return d.lang; }}),
7610 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 }}]
7611 }},
7612 options: {{
7613 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7614 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7615 scales: {{
7616 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7617 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7618 }}
7619 }}
7620 }});
7621 ALL_CHARTS.push(densityChart);
7622 }}
7623 }}
7624
7625 function renderCovCharts(covD, tiers) {{
7626 covChart = destroyChart(covChart);
7627 tierChart = destroyChart(tierChart);
7628 var covCanvas = document.getElementById('canvas-cov');
7629 if (covCanvas && covD && covD.length) {{
7630 covChart = new Chart(covCanvas, {{
7631 type: 'bar',
7632 data: {{
7633 labels: covD.map(function(d){{ return d.lang; }}),
7634 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 }}]
7635 }},
7636 options: {{
7637 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7638 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7639 scales: {{
7640 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7641 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7642 }}
7643 }}
7644 }});
7645 ALL_CHARTS.push(covChart);
7646 }}
7647 var tierCanvas = document.getElementById('canvas-cov-tiers');
7648 if (tierCanvas && tiers) {{
7649 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7650 tierChart = new Chart(tierCanvas, {{
7651 type: 'doughnut',
7652 data: {{
7653 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7654 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7655 }},
7656 options: {{
7657 responsive: true, maintainAspectRatio: false, cutout: '62%',
7658 plugins: {{
7659 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7660 tooltip: {{ callbacks: {{ label: function(ctx) {{
7661 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7662 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7663 }} }} }}
7664 }}
7665 }}
7666 }});
7667 ALL_CHARTS.push(tierChart);
7668 }}
7669 }}
7670
7671 function buildLangTable(D) {{
7672 var tbody = document.getElementById('lang-tbody');
7673 if (!tbody) return;
7674 if (!D || !D.length) {{
7675 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>';
7676 return;
7677 }}
7678 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7679 tbody.innerHTML = D.map(function(d) {{
7680 var barW = Math.round(d.density / maxDensity * 120);
7681 return '<tr>' +
7682 '<td><strong>' + d.lang + '</strong></td>' +
7683 '<td class="num">' + fmt(d.tests) + '</td>' +
7684 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7685 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7686 '<td class="num">' + fmt(d.code) + '</td>' +
7687 '<td class="num">' + fmt(d.files) + '</td>' +
7688 '<td class="num">' + d.density.toFixed(2) + '</td>' +
7689 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7690 '</tr>';
7691 }}).join('');
7692 }}
7693
7694 var covFileData = [];
7695 var covFileTier = 'all';
7696 var covFileSearch = '';
7697
7698 function pctBadge(pct) {{
7699 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7700 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7701 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7702 }}
7703
7704 function buildCovFileTable() {{
7705 var tbody = document.getElementById('cov-file-tbody');
7706 var empty = document.getElementById('cov-file-empty');
7707 var count = document.getElementById('cov-file-count');
7708 if (!tbody) return;
7709 var srch = covFileSearch.toLowerCase();
7710 var filtered = covFileData.filter(function(f) {{
7711 if (covFileTier === 'zero' && f.line_pct > 0) return false;
7712 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7713 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7714 if (covFileTier === 'high' && f.line_pct < 80) return false;
7715 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7716 return true;
7717 }});
7718 if (!filtered.length) {{
7719 tbody.innerHTML = '';
7720 if (empty) empty.style.display = '';
7721 if (count) count.textContent = '';
7722 return;
7723 }}
7724 if (empty) empty.style.display = 'none';
7725 var shown = Math.min(filtered.length, 500);
7726 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7727 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7728 var fnCol = f.fn_pct < 0
7729 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7730 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7731 return '<tr>' +
7732 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
7733 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7734 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7735 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7736 fnCol +
7737 '</tr>';
7738 }}).join('');
7739 }}
7740
7741 (function() {{
7742 var tabs = document.getElementById('cov-filter-tabs');
7743 if (tabs) {{
7744 tabs.addEventListener('click', function(e) {{
7745 var btn = e.target.closest('.cov-tab');
7746 if (!btn) return;
7747 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7748 btn.classList.add('active');
7749 covFileTier = btn.getAttribute('data-tier');
7750 buildCovFileTable();
7751 }});
7752 }}
7753 var srch = document.getElementById('cov-file-search');
7754 if (srch) {{
7755 srch.addEventListener('input', function() {{
7756 covFileSearch = this.value;
7757 buildCovFileTable();
7758 }});
7759 }}
7760 }})();
7761
7762 function updateCovGauges(t) {{
7763 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
7764 var el;
7765 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
7766 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
7767 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
7768 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
7769 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
7770 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
7771 }}
7772
7773 function applyScope() {{
7774 var d = getDataset();
7775 var t = d.totals;
7776 var el;
7777 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
7778 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
7779 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
7780 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
7781 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
7782 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
7783 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
7784 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
7785 renderTestCharts(d.lang_tests);
7786 buildLangTable(d.lang_tests);
7787 var covPanel = document.getElementById('cov-panel');
7788 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
7789 if (d.has_coverage) {{
7790 renderCovCharts(d.cov, d.cov_tiers);
7791 updateCovGauges(t);
7792 covFileData = d.file_cov || [];
7793 covFileTier = 'all';
7794 covFileSearch = '';
7795 var tabs = document.getElementById('cov-filter-tabs');
7796 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
7797 var srch = document.getElementById('cov-file-search');
7798 if (srch) srch.value = '';
7799 buildCovFileTable();
7800 }}
7801 loadTrend();
7802 }}
7803
7804 // Populate scope-root-sel from SCOPE_DATA keys
7805 (function() {{
7806 var sel = document.getElementById('scope-root-sel');
7807 if (!sel) return;
7808 Object.keys(SCOPE_DATA).forEach(function(k) {{
7809 if (k === '__all__') return;
7810 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
7811 }});
7812 }})();
7813
7814 document.getElementById('scope-root-sel').addEventListener('change', function() {{
7815 currentRoot = this.value;
7816 currentSub = '';
7817 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7818 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
7819 var subWrap = document.getElementById('scope-sub-wrap');
7820 var subSel = document.getElementById('scope-sub-sel');
7821 subSel.innerHTML = '<option value="">Entire project</option>';
7822 if (subNames.length) {{
7823 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
7824 subWrap.style.display = 'flex';
7825 }} else {{
7826 subWrap.style.display = 'none';
7827 }}
7828 applyScope();
7829 }});
7830
7831 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
7832 currentSub = this.value;
7833 applyScope();
7834 }});
7835
7836 function buildTrend(data) {{
7837 var trendCanvas = document.getElementById('canvas-trend');
7838 var trendEmpty = document.getElementById('trend-empty');
7839 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
7840 pts = pts.slice().reverse();
7841 if (!pts.length) {{
7842 if (trendCanvas) trendCanvas.style.display = 'none';
7843 if (trendEmpty) trendEmpty.style.display = '';
7844 return;
7845 }}
7846 if (trendCanvas) trendCanvas.style.display = '';
7847 if (trendEmpty) trendEmpty.style.display = 'none';
7848 trendChart = destroyChart(trendChart);
7849 if (!trendCanvas) return;
7850 trendChart = new Chart(trendCanvas, {{
7851 type: 'line',
7852 data: {{
7853 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
7854 datasets: [{{
7855 label: 'Test Definitions',
7856 data: pts.map(function(d){{ return d.test_count; }}),
7857 borderColor: '#C45C10',
7858 backgroundColor: 'rgba(196,92,16,0.10)',
7859 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
7860 pointRadius: 5, fill: true, tension: 0.3
7861 }}]
7862 }},
7863 options: {{
7864 responsive: true, maintainAspectRatio: false,
7865 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
7866 scales: {{
7867 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
7868 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
7869 }}
7870 }}
7871 }});
7872 ALL_CHARTS.push(trendChart);
7873 }}
7874
7875 function loadTrend() {{
7876 var url = '/api/metrics/history?limit=100';
7877 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
7878 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
7879 buildTrend(data);
7880 }}).catch(function(){{
7881 var trendEmpty = document.getElementById('trend-empty');
7882 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
7883 }});
7884 }}
7885
7886 // Re-render charts on theme toggle
7887 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
7888 setTimeout(function() {{
7889 ALL_CHARTS.forEach(function(c) {{
7890 if (c && c.options && c.options.scales) {{
7891 Object.values(c.options.scales).forEach(function(ax) {{
7892 if (ax.grid) ax.grid.color = clr();
7893 if (ax.ticks) ax.ticks.color = txtClr();
7894 }});
7895 c.update();
7896 }}
7897 }});
7898 }}, 80);
7899 }});
7900
7901 applyScope();
7902 }})();
7903 </script>
7904</body>
7905</html>"#,
7906 );
7907 Html(html).into_response()
7908}
7909
7910#[derive(Deserialize)]
7917struct EmbedQuery {
7918 run_id: Option<String>,
7919 theme: Option<String>,
7920}
7921
7922async fn embed_handler(
7923 State(state): State<AppState>,
7924 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7925 Query(query): Query<EmbedQuery>,
7926) -> Response {
7927 let entry = {
7928 let reg = state.registry.lock().await;
7929 query.run_id.as_ref().map_or_else(
7930 || reg.entries.first().cloned(),
7931 |id| reg.find_by_run_id(id).cloned(),
7932 )
7933 };
7934
7935 let Some(entry) = entry else {
7936 return Html(
7937 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
7938 .to_string(),
7939 )
7940 .into_response();
7941 };
7942
7943 let dark = query.theme.as_deref() == Some("dark");
7944 let languages: Vec<(String, u64, u64)> = entry
7945 .json_path
7946 .as_ref()
7947 .and_then(|p| read_json(p).ok())
7948 .map(|run| {
7949 run.totals_by_language
7950 .iter()
7951 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
7952 .collect()
7953 })
7954 .unwrap_or_default();
7955
7956 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
7957}
7958
7959fn render_embed_widget(
7960 entry: &RegistryEntry,
7961 languages: &[(String, u64, u64)],
7962 dark: bool,
7963 csp_nonce: &str,
7964) -> String {
7965 let s = &entry.summary;
7966 let total = s.code_lines + s.comment_lines + s.blank_lines;
7967 let code_pct = s
7968 .code_lines
7969 .checked_mul(100)
7970 .and_then(|n| n.checked_div(total))
7971 .unwrap_or(0);
7972
7973 let (bg, fg, surface, muted, border) = if dark {
7974 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
7975 } else {
7976 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
7977 };
7978
7979 let mut lang_rows = String::new();
7980 for (name, files, code) in languages {
7981 write!(
7982 lang_rows,
7983 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
7984 escape_html(name),
7985 format_number(*files),
7986 format_number(*code),
7987 )
7988 .ok();
7989 }
7990
7991 let lang_table = if lang_rows.is_empty() {
7992 String::new()
7993 } else {
7994 format!(
7995 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
7996 )
7997 };
7998
7999 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8000 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8001 let project_esc = escape_html(&entry.project_label);
8002 let code_lines = format_number(s.code_lines);
8003 let comment_lines = format_number(s.comment_lines);
8004 let files = format_number(s.files_analyzed);
8005 let code_raw = s.code_lines;
8006 let comment_raw = s.comment_lines;
8007 let blank_raw = s.blank_lines;
8008
8009 format!(
8010 r#"<!doctype html>
8011<html lang="en">
8012<head>
8013 <meta charset="utf-8">
8014 <meta name="viewport" content="width=device-width,initial-scale=1">
8015 <title>OxideSLOC — {project_esc}</title>
8016 <script src="/static/chart.js"></script>
8017 <style nonce="{csp_nonce}">
8018 *{{box-sizing:border-box;margin:0;padding:0}}
8019 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8020 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8021 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8022 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8023 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8024 .card .v{{font-size:18px;font-weight:700}}
8025 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8026 .row{{display:flex;gap:12px;align-items:flex-start}}
8027 .pie{{width:120px;height:120px;flex-shrink:0}}
8028 .lt{{border-collapse:collapse;width:100%;flex:1}}
8029 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8030 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8031 .n{{text-align:right}}
8032 .footer{{margin-top:10px;color:{muted};font-size:10px}}
8033 </style>
8034</head>
8035<body>
8036 <h2>{project_esc}</h2>
8037 <div class="sub">{timestamp} · run {run_short}</div>
8038 <div class="cards">
8039 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8040 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8041 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8042 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8043 </div>
8044 <div class="row">
8045 <canvas class="pie" id="c"></canvas>
8046 {lang_table}
8047 </div>
8048 <div class="footer">oxide-sloc</div>
8049 <script nonce="{csp_nonce}">
8050 new Chart(document.getElementById('c'),{{
8051 type:'doughnut',
8052 data:{{
8053 labels:['Code','Comments','Blank'],
8054 datasets:[{{
8055 data:[{code_raw},{comment_raw},{blank_raw}],
8056 backgroundColor:['#4a78ee','#b35428','#aaa'],
8057 borderWidth:0
8058 }}]
8059 }},
8060 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8061 }});
8062 </script>
8063</body>
8064</html>"#
8065 )
8066}
8067
8068#[allow(clippy::too_many_arguments)]
8069fn persist_run_artifacts(
8070 run: &sloc_core::AnalysisRun,
8071 report_html: &str,
8072 run_dir: &Path,
8073 generate_json: bool,
8074 generate_html: bool,
8075 generate_pdf: bool,
8076 report_title: &str,
8077 file_stem: &str,
8078 result_context: RunResultContext,
8079) -> Result<(RunArtifacts, PendingPdf)> {
8080 fs::create_dir_all(run_dir)
8081 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8082
8083 let mut html_path = None;
8084 let mut pdf_path = None;
8085 let mut json_path = None;
8086 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8087
8088 if generate_html {
8089 let path = run_dir.join(format!("report_{file_stem}.html"));
8090 fs::write(&path, report_html)
8091 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8092 html_path = Some(path);
8093 }
8094
8095 if generate_json {
8096 let path = run_dir.join(format!("result_{file_stem}.json"));
8097 let json = serde_json::to_string_pretty(run)
8098 .context("failed to serialize analysis run to JSON")?;
8099 fs::write(&path, json)
8100 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8101 json_path = Some(path);
8102 }
8103
8104 if generate_pdf {
8105 let source_html_path = if let Some(existing) = html_path.as_ref() {
8106 existing.clone()
8107 } else {
8108 let temp_html = run_dir.join("_report_rendered.html");
8109 fs::write(&temp_html, report_html).with_context(|| {
8110 format!(
8111 "failed to write temporary HTML report to {}",
8112 temp_html.display()
8113 )
8114 })?;
8115 temp_html
8116 };
8117
8118 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8119 let cleanup_src = !generate_html;
8120 pdf_path = Some(pdf_dest.clone());
8121 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8122 }
8123
8124 let csv_path = {
8126 let path = run_dir.join(format!("report_{file_stem}.csv"));
8127 if let Err(e) = sloc_report::write_csv(run, &path) {
8128 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
8129 None
8130 } else {
8131 Some(path)
8132 }
8133 };
8134
8135 let xlsx_path = {
8136 let path = run_dir.join(format!("report_{file_stem}.xlsx"));
8137 if let Err(e) = sloc_report::write_xlsx(run, &path) {
8138 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
8139 None
8140 } else {
8141 Some(path)
8142 }
8143 };
8144
8145 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8146
8147 Ok((
8148 RunArtifacts {
8149 output_dir: run_dir.to_path_buf(),
8150 html_path,
8151 pdf_path,
8152 json_path,
8153 csv_path,
8154 xlsx_path,
8155 scan_config_path,
8156 report_title: report_title.to_string(),
8157 result_context,
8158 },
8159 pending_pdf,
8160 ))
8161}
8162
8163fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8166 let exact = dir.join("scan-config.json");
8167 if exact.exists() {
8168 return Some(exact);
8169 }
8170 fs::read_dir(dir).ok().and_then(|entries| {
8171 entries
8172 .filter_map(std::result::Result::ok)
8173 .find(|e| {
8174 let name = e.file_name();
8175 let name = name.to_string_lossy();
8176 name.starts_with("scan-config") && name.ends_with(".json")
8177 })
8178 .map(|e| e.path())
8179 })
8180}
8181
8182async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8185 let toml_str = match toml::to_string_pretty(&state.base_config) {
8186 Ok(s) => s,
8187 Err(e) => {
8188 return (
8189 StatusCode::INTERNAL_SERVER_ERROR,
8190 format!("serialization error: {e}"),
8191 )
8192 .into_response();
8193 }
8194 };
8195 (
8196 [
8197 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8198 (
8199 header::CONTENT_DISPOSITION,
8200 "attachment; filename=\".oxide-sloc.toml\"",
8201 ),
8202 ],
8203 toml_str,
8204 )
8205 .into_response()
8206}
8207
8208#[derive(Serialize)]
8209struct OkResponse {
8210 ok: bool,
8211}
8212
8213#[derive(Serialize)]
8214struct SaveProfileResponse {
8215 ok: bool,
8216 id: String,
8217}
8218
8219#[derive(Serialize)]
8220struct ProfileListResponse {
8221 profiles: Vec<ScanProfile>,
8222}
8223
8224#[derive(Serialize)]
8225struct ImportConfigResponse {
8226 ok: bool,
8227 config: sloc_config::AppConfig,
8228}
8229
8230#[derive(Deserialize)]
8231struct ImportConfigBody {
8232 toml: String,
8233}
8234
8235async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8236 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8237 Ok(config) => {
8238 if let Err(e) = config.validate() {
8239 return error::unprocessable_entity(&e.to_string());
8240 }
8241 Json(ImportConfigResponse { ok: true, config }).into_response()
8242 }
8243 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
8244 }
8245}
8246
8247async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8250 let store = state.scan_profiles.lock().await;
8251 Json(ProfileListResponse {
8252 profiles: store.profiles.clone(),
8253 })
8254}
8255
8256#[derive(Deserialize)]
8257struct SaveScanProfileBody {
8258 name: String,
8259 params: serde_json::Value,
8260}
8261
8262async fn api_save_scan_profile(
8263 State(state): State<AppState>,
8264 Json(body): Json<SaveScanProfileBody>,
8265) -> impl IntoResponse {
8266 if body.name.trim().is_empty() {
8267 return error::bad_request("name must not be empty");
8268 }
8269
8270 let id = uuid::Uuid::new_v4().to_string();
8271 let profile = ScanProfile {
8272 id: id.clone(),
8273 name: body.name.trim().to_string(),
8274 created_at: chrono::Utc::now().to_rfc3339(),
8275 params: body.params,
8276 };
8277
8278 let mut store = state.scan_profiles.lock().await;
8279 store.profiles.push(profile);
8280 if let Err(e) = store.save(&state.scan_profiles_path) {
8281 tracing::warn!("failed to persist scan profiles: {e}");
8282 }
8283 drop(store);
8284
8285 (
8286 StatusCode::CREATED,
8287 Json(SaveProfileResponse { ok: true, id }),
8288 )
8289 .into_response()
8290}
8291
8292async fn api_delete_scan_profile(
8293 State(state): State<AppState>,
8294 AxumPath(id): AxumPath<String>,
8295) -> impl IntoResponse {
8296 let mut store = state.scan_profiles.lock().await;
8297 let before = store.profiles.len();
8298 store.profiles.retain(|p| p.id != id);
8299 if store.profiles.len() == before {
8300 drop(store);
8301 return error::not_found("profile not found");
8302 }
8303 if let Err(e) = store.save(&state.scan_profiles_path) {
8304 tracing::warn!("failed to persist scan profiles: {e}");
8305 }
8306 drop(store);
8307 Json(OkResponse { ok: true }).into_response()
8308}
8309
8310fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8311 let value = raw.unwrap_or("out/web").trim();
8312 let path = if value.is_empty() {
8313 PathBuf::from("out/web")
8314 } else {
8315 PathBuf::from(value)
8316 };
8317
8318 if path.is_absolute() {
8319 path
8320 } else {
8321 workspace_root().join(path)
8322 }
8323}
8324
8325fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8327 std::env::var("SLOC_GIT_CLONES_DIR")
8328 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8329}
8330
8331pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8334 let safe: String = repo_url
8335 .chars()
8336 .map(|c| {
8337 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8338 c
8339 } else {
8340 '_'
8341 }
8342 })
8343 .take(80)
8344 .collect();
8345 clones_dir.join(safe)
8346}
8347
8348pub(crate) fn scan_path_to_artifacts(
8351 scan_path: &Path,
8352 base_config: &AppConfig,
8353 label: &str,
8354) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8355 let mut config = base_config.clone();
8356 config.discovery.root_paths = vec![scan_path.to_path_buf()];
8357 label.clone_into(&mut config.reporting.report_title);
8358 let run = analyze(&config, "git", None)?;
8359 let html = render_html(&run)?;
8360 let run_id = run.tool.run_id.clone();
8361 let project_label = sanitize_project_label(label);
8362 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8363 let file_stem = {
8364 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8365 if commit.is_empty() {
8366 project_label
8367 } else {
8368 format!("{project_label}_{commit}")
8369 }
8370 };
8371 let (artifacts, _pending_pdf) = persist_run_artifacts(
8372 &run,
8373 &html,
8374 &output_dir,
8375 true,
8376 true,
8377 false,
8378 label,
8379 &file_stem,
8380 RunResultContext::default(),
8381 )?;
8382 Ok((run_id, artifacts, run))
8383}
8384
8385async fn restart_poll_schedules(state: &AppState) {
8387 let store = state.schedules.lock().await;
8388 let poll_schedules: Vec<_> = store
8389 .schedules
8390 .iter()
8391 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8392 .cloned()
8393 .collect();
8394 drop(store);
8395 for schedule in poll_schedules {
8396 let interval = schedule.interval_secs.unwrap_or(300);
8397 let st = state.clone();
8398 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8399 }
8400}
8401
8402fn split_patterns(raw: Option<&str>) -> Vec<String> {
8403 raw.unwrap_or("")
8404 .lines()
8405 .flat_map(|line| line.split(','))
8406 .map(str::trim)
8407 .filter(|part| !part.is_empty())
8408 .map(ToOwned::to_owned)
8409 .collect()
8410}
8411
8412fn build_sub_run(
8413 parent: &AnalysisRun,
8414 sub: &sloc_core::SubmoduleSummary,
8415 parent_path: &str,
8416) -> AnalysisRun {
8417 let sub_files: Vec<_> = parent
8418 .per_file_records
8419 .iter()
8420 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8421 .cloned()
8422 .collect();
8423 let mut config = parent.effective_configuration.clone();
8424 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8425 AnalysisRun {
8426 tool: parent.tool.clone(),
8427 environment: parent.environment.clone(),
8428 effective_configuration: config,
8429 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8430 summary_totals: SummaryTotals {
8431 files_considered: sub.files_analyzed,
8432 files_analyzed: sub.files_analyzed,
8433 files_skipped: 0,
8434 total_physical_lines: sub.total_physical_lines,
8435 code_lines: sub.code_lines,
8436 comment_lines: sub.comment_lines,
8437 blank_lines: sub.blank_lines,
8438 mixed_lines_separate: 0,
8439 functions: 0,
8440 classes: 0,
8441 variables: 0,
8442 imports: 0,
8443 test_count: 0,
8444 test_assertion_count: 0,
8445 test_suite_count: 0,
8446 coverage_lines_found: 0,
8447 coverage_lines_hit: 0,
8448 coverage_functions_found: 0,
8449 coverage_functions_hit: 0,
8450 coverage_branches_found: 0,
8451 coverage_branches_hit: 0,
8452 },
8453 totals_by_language: sub.language_summaries.clone(),
8454 per_file_records: sub_files,
8455 skipped_file_records: vec![],
8456 warnings: vec![],
8457 submodule_summaries: vec![],
8458 git_commit_short: parent.git_commit_short.clone(),
8459 git_commit_long: parent.git_commit_long.clone(),
8460 git_branch: parent.git_branch.clone(),
8461 git_commit_author: parent.git_commit_author.clone(),
8462 git_commit_date: parent.git_commit_date.clone(),
8463 git_tags: parent.git_tags.clone(),
8464 git_nearest_tag: parent.git_nearest_tag.clone(),
8465 }
8466}
8467
8468pub(crate) fn sanitize_project_label(raw: &str) -> String {
8469 let candidate = Path::new(raw)
8470 .file_name()
8471 .and_then(|name| name.to_str())
8472 .unwrap_or("project");
8473
8474 let mut value = String::with_capacity(candidate.len());
8475 for ch in candidate.chars() {
8476 if ch.is_ascii_alphanumeric() {
8477 value.push(ch.to_ascii_lowercase());
8478 } else {
8479 value.push('-');
8480 }
8481 }
8482
8483 let compact = value.trim_matches('-').to_string();
8484 if compact.is_empty() {
8485 "project".to_string()
8486 } else {
8487 compact
8488 }
8489}
8490
8491fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8494 let s = path.to_string_lossy();
8495 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8496 return PathBuf::from(format!(r"\\{rest}"));
8497 }
8498 if let Some(rest) = s.strip_prefix(r"\\?\") {
8499 return PathBuf::from(rest);
8500 }
8501 path
8502}
8503
8504fn display_path(path: &Path) -> String {
8505 let s = path.to_string_lossy();
8506 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8511 return format!(r"\\{rest}");
8512 }
8513 if let Some(rest) = s.strip_prefix(r"\\?\") {
8514 return rest.to_owned();
8515 }
8516 s.into_owned()
8517}
8518
8519fn sanitize_path_str(s: &str) -> String {
8520 if let Some(rest) = s.strip_prefix("//?/UNC/") {
8524 return format!("//{rest}");
8525 }
8526 if let Some(rest) = s.strip_prefix("//?/") {
8527 return rest.to_owned();
8528 }
8529 display_path(Path::new(s))
8530}
8531
8532fn workspace_root() -> PathBuf {
8533 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8535 let p = PathBuf::from(root);
8536 if p.is_dir() {
8537 return p;
8538 }
8539 }
8540
8541 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8544}
8545
8546fn make_git_label(repo: &str, ref_name: &str) -> String {
8548 if repo.is_empty() || ref_name.is_empty() {
8549 return String::new();
8550 }
8551 let base = repo
8552 .trim_end_matches('/')
8553 .trim_end_matches(".git")
8554 .rsplit('/')
8555 .next()
8556 .unwrap_or("repo");
8557 let ref_safe: String = ref_name
8558 .chars()
8559 .map(|c| {
8560 if c.is_alphanumeric() || c == '-' || c == '.' {
8561 c
8562 } else {
8563 '_'
8564 }
8565 })
8566 .collect();
8567 format!("{base}_at_{ref_safe}_sloc")
8568}
8569
8570fn desktop_dir() -> PathBuf {
8572 if let Ok(profile) = std::env::var("USERPROFILE") {
8573 let p = PathBuf::from(profile).join("Desktop");
8574 if p.exists() {
8575 return p;
8576 }
8577 }
8578 if let Ok(home) = std::env::var("HOME") {
8579 let p = PathBuf::from(home).join("Desktop");
8580 if p.exists() {
8581 return p;
8582 }
8583 }
8584 workspace_root().join("out").join("web")
8585}
8586
8587fn resolve_input_path(raw: &str) -> PathBuf {
8588 let trimmed = raw.trim();
8589 if trimmed.is_empty() {
8590 return workspace_root().join("samples").join("basic");
8591 }
8592
8593 let candidate = PathBuf::from(trimmed);
8594 let resolved = if candidate.is_absolute() {
8595 candidate
8596 } else {
8597 let rooted = workspace_root().join(&candidate);
8598 if rooted.exists() {
8599 rooted
8600 } else {
8601 workspace_root().join(candidate)
8602 }
8603 };
8604
8605 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8608 PathBuf::from(display_path(&canonical))
8609}
8610
8611fn dir_size_bytes(path: &Path) -> u64 {
8612 let mut total = 0u64;
8613 if let Ok(rd) = fs::read_dir(path) {
8614 for entry in rd.filter_map(Result::ok) {
8615 let p = entry.path();
8616 if p.is_file() {
8617 if let Ok(meta) = p.metadata() {
8618 total += meta.len();
8619 }
8620 } else if p.is_dir() {
8621 total += dir_size_bytes(&p);
8622 }
8623 }
8624 }
8625 total
8626}
8627
8628#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
8630 if bytes >= 1_073_741_824 {
8631 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8632 } else if bytes >= 1_048_576 {
8633 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8634 } else if bytes >= 1_024 {
8635 format!("{:.0} KB", bytes as f64 / 1_024.0)
8636 } else {
8637 format!("{bytes} B")
8638 }
8639}
8640
8641fn render_submodule_chips(
8642 root: &Path,
8643 submodules: &[(String, std::path::PathBuf)],
8644 out: &mut String,
8645) {
8646 use std::fmt::Write as _;
8647 let count = submodules.len();
8648 out.push_str(r#"<div class="submodule-preview-strip">"#);
8649 write!(
8650 out,
8651 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>"#,
8652 if count == 1 { "" } else { "s" }
8653 )
8654 .ok();
8655 out.push_str(r#"<div class="submodule-preview-chips">"#);
8656 for (sub_name, sub_rel_path) in submodules {
8657 let sub_abs = root.join(sub_rel_path);
8658 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8659 let mut sub_stats = PreviewStats::default();
8660 let mut sub_rows: Vec<PreviewRow> = Vec::new();
8661 let mut sub_langs: Vec<&'static str> = Vec::new();
8662 let mut sub_budget = PreviewBudget {
8663 shown: 0,
8664 max_entries: 2000,
8665 max_depth: 9,
8666 };
8667 let mut sub_next_id = 1usize;
8668 let _ = collect_preview_rows(
8669 &sub_abs,
8670 &sub_abs,
8671 0,
8672 None,
8673 &mut sub_next_id,
8674 &mut sub_budget,
8675 &mut sub_stats,
8676 &mut sub_rows,
8677 &mut sub_langs,
8678 &[],
8679 &[],
8680 );
8681 let stats_json = format!(
8682 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
8683 sub_stats.directories,
8684 sub_stats.files,
8685 sub_stats.supported,
8686 sub_stats.skipped,
8687 sub_stats.unsupported
8688 );
8689 write!(
8690 out,
8691 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>"#,
8692 escape_html(sub_name),
8693 escape_html(&sub_rel_path.to_string_lossy()),
8694 escape_html(&sub_size),
8695 escape_html(&stats_json),
8696 escape_html(sub_name),
8697 escape_html(&sub_size),
8698 )
8699 .ok();
8700 }
8701 out.push_str(
8702 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
8703 );
8704 out.push_str(r"</div>");
8705}
8706
8707fn render_language_pills_row(languages: &[&str], out: &mut String) {
8708 use std::fmt::Write as _;
8709 if languages.is_empty() {
8710 out.push_str(
8711 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
8712 );
8713 return;
8714 }
8715 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
8716 for language in languages {
8717 if let Some(icon) = language_icon_file(language) {
8718 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();
8719 } else if let Some(svg) = language_inline_svg(language) {
8720 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();
8721 } else {
8722 write!(
8723 out,
8724 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
8725 escape_html(&language.to_ascii_lowercase()),
8726 escape_html(language)
8727 )
8728 .ok();
8729 }
8730 }
8731}
8732
8733#[allow(clippy::too_many_lines)]
8734fn build_preview_html(
8735 root: &Path,
8736 include_patterns: &[String],
8737 exclude_patterns: &[String],
8738) -> Result<String> {
8739 if !root.exists() {
8740 return Ok(format!(
8741 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8742 escape_html(&display_path(root))
8743 ));
8744 }
8745
8746 let _selected = display_path(root);
8747 let mut stats = PreviewStats::default();
8748 let mut rows = Vec::new();
8749 let mut languages = Vec::new();
8750 let mut budget = PreviewBudget {
8751 shown: 0,
8752 max_entries: 600,
8753 max_depth: 9,
8754 };
8755 let mut next_row_id = 1usize;
8756
8757 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8758 || root.to_string_lossy().into_owned(),
8759 std::string::ToString::to_string,
8760 );
8761 let root_modified = root
8762 .metadata()
8763 .ok()
8764 .and_then(|meta| meta.modified().ok())
8765 .map_or_else(|| "-".to_string(), format_system_time);
8766
8767 rows.push(PreviewRow {
8768 row_id: 0,
8769 parent_row_id: None,
8770 depth: 0,
8771 name: format!("{root_name}/"),
8772 kind: PreviewKind::Dir,
8773 is_dir: true,
8774 language: None,
8775 modified: root_modified,
8776 type_label: "Directory".to_string(),
8777 });
8778 collect_preview_rows(
8779 root,
8780 root,
8781 0,
8782 Some(0),
8783 &mut next_row_id,
8784 &mut budget,
8785 &mut stats,
8786 &mut rows,
8787 &mut languages,
8788 include_patterns,
8789 exclude_patterns,
8790 )?;
8791
8792 let root_size = format_dir_size(dir_size_bytes(root));
8793
8794 let mut out = String::new();
8795 write!(
8796 out,
8797 r#"<div class="explorer-wrap" data-project-size="{}">"#,
8798 escape_html(&root_size)
8799 )
8800 .ok();
8801 out.push_str(r#"<div class="explorer-toolbar compact">"#);
8802 out.push_str(r#"<div class="explorer-title-group">"#);
8803 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8804 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8805 out.push_str(r"</div></div>");
8806
8807 out.push_str(r#"<div class="scope-stats">"#);
8808 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();
8809 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();
8810 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();
8811 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();
8812 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();
8813 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>"#);
8814 out.push_str(r"</div>");
8815
8816 let submodules = sloc_core::detect_submodules(root);
8817 if !submodules.is_empty() {
8818 render_submodule_chips(root, &submodules, &mut out);
8819 }
8820
8821 out.push_str(r#"<div class="scope-info-row">"#);
8822 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
8823 render_language_pills_row(&languages, &mut out);
8824 out.push_str(r"</div></div>");
8825 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>"#);
8826 out.push_str(r"</div>");
8827
8828 out.push_str(r#"<div class="file-explorer-shell">"#);
8829 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>"#);
8830 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>"#);
8831 out.push_str(r#"<div class="file-explorer-tree">"#);
8832 for row in rows {
8833 let status_label = row.kind.label();
8834 let lang_attr = row.language.unwrap_or("");
8835 let toggle_html = if row.is_dir {
8836 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
8837 .to_string()
8838 } else {
8839 r#"<span class="tree-bullet">•</span>"#.to_string()
8840 };
8841 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();
8842 }
8843 if budget.shown >= budget.max_entries {
8844 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>"#);
8845 }
8846 out.push_str(r"</div></div></div>");
8847
8848 Ok(out)
8849}
8850
8851#[derive(Default)]
8852struct PreviewStats {
8853 directories: usize,
8854 files: usize,
8855 supported: usize,
8856 skipped: usize,
8857 unsupported: usize,
8858}
8859
8860struct PreviewRow {
8861 row_id: usize,
8862 parent_row_id: Option<usize>,
8863 depth: usize,
8864 name: String,
8865 kind: PreviewKind,
8866 is_dir: bool,
8867 language: Option<&'static str>,
8868 modified: String,
8869 type_label: String,
8870}
8871
8872#[derive(Copy, Clone)]
8873enum PreviewKind {
8874 Dir,
8875 Supported,
8876 Skipped,
8877 Unsupported,
8878}
8879
8880impl PreviewKind {
8881 const fn filter_key(self) -> &'static str {
8882 match self {
8883 Self::Dir => "dir",
8884 Self::Supported => "supported",
8885 Self::Skipped => "skipped",
8886 Self::Unsupported => "unsupported",
8887 }
8888 }
8889
8890 const fn label(self) -> &'static str {
8891 match self {
8892 Self::Dir => "dir",
8893 Self::Supported => "supported",
8894 Self::Skipped => "skipped by policy",
8895 Self::Unsupported => "unsupported",
8896 }
8897 }
8898
8899 const fn badge_class(self) -> &'static str {
8900 match self {
8901 Self::Dir => "badge badge-dir",
8902 Self::Supported => "badge badge-scan",
8903 Self::Skipped => "badge badge-skip",
8904 Self::Unsupported => "badge badge-unsupported",
8905 }
8906 }
8907
8908 const fn node_class(self) -> &'static str {
8909 match self {
8910 Self::Dir => "tree-node-dir",
8911 Self::Supported => "tree-node-supported",
8912 Self::Skipped => "tree-node-skipped",
8913 Self::Unsupported => "tree-node-unsupported",
8914 }
8915 }
8916}
8917
8918struct PreviewBudget {
8919 shown: usize,
8920 max_entries: usize,
8921 max_depth: usize,
8922}
8923
8924#[allow(clippy::too_many_arguments)]
8927fn handle_preview_dir_entry(
8928 root: &Path,
8929 path: &Path,
8930 name: &str,
8931 modified: String,
8932 depth: usize,
8933 parent_row_id: Option<usize>,
8934 row_id: usize,
8935 next_row_id: &mut usize,
8936 budget: &mut PreviewBudget,
8937 stats: &mut PreviewStats,
8938 rows: &mut Vec<PreviewRow>,
8939 languages: &mut Vec<&'static str>,
8940 include_patterns: &[String],
8941 exclude_patterns: &[String],
8942) -> Result<()> {
8943 let relative = preview_relative_path(root, path);
8944 if should_skip_preview_directory(&relative, exclude_patterns) {
8945 return Ok(());
8946 }
8947 stats.directories += 1;
8948 rows.push(PreviewRow {
8949 row_id,
8950 parent_row_id,
8951 depth: depth + 1,
8952 name: format!("{name}/"),
8953 kind: PreviewKind::Dir,
8954 is_dir: true,
8955 language: None,
8956 modified,
8957 type_label: "Directory".to_string(),
8958 });
8959 budget.shown += 1;
8960 if !matches!(name, ".git" | "node_modules" | "target") {
8961 collect_preview_rows(
8962 root,
8963 path,
8964 depth + 1,
8965 Some(row_id),
8966 next_row_id,
8967 budget,
8968 stats,
8969 rows,
8970 languages,
8971 include_patterns,
8972 exclude_patterns,
8973 )?;
8974 }
8975 Ok(())
8976}
8977
8978#[allow(clippy::too_many_arguments)]
8980fn handle_preview_file_entry(
8981 root: &Path,
8982 path: &Path,
8983 name: &str,
8984 modified: String,
8985 depth: usize,
8986 parent_row_id: Option<usize>,
8987 row_id: usize,
8988 budget: &mut PreviewBudget,
8989 stats: &mut PreviewStats,
8990 rows: &mut Vec<PreviewRow>,
8991 languages: &mut Vec<&'static str>,
8992 include_patterns: &[String],
8993 exclude_patterns: &[String],
8994) {
8995 let relative = preview_relative_path(root, path);
8996 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
8997 return;
8998 }
8999 stats.files += 1;
9000 let kind = classify_preview_file(name);
9001 match kind {
9002 PreviewKind::Supported => stats.supported += 1,
9003 PreviewKind::Skipped => stats.skipped += 1,
9004 PreviewKind::Unsupported => stats.unsupported += 1,
9005 PreviewKind::Dir => {}
9006 }
9007 let language = detect_language_name(name);
9008 if let Some(lang) = language {
9009 if !languages.contains(&lang) {
9010 languages.push(lang);
9011 }
9012 }
9013 rows.push(PreviewRow {
9014 row_id,
9015 parent_row_id,
9016 depth: depth + 1,
9017 name: name.to_owned(),
9018 kind,
9019 is_dir: false,
9020 language,
9021 modified,
9022 type_label: preview_type_label(name, language, kind),
9023 });
9024 budget.shown += 1;
9025}
9026
9027#[allow(clippy::too_many_arguments)]
9028#[allow(clippy::too_many_lines)]
9029fn collect_preview_rows(
9030 root: &Path,
9031 dir: &Path,
9032 depth: usize,
9033 parent_row_id: Option<usize>,
9034 next_row_id: &mut usize,
9035 budget: &mut PreviewBudget,
9036 stats: &mut PreviewStats,
9037 rows: &mut Vec<PreviewRow>,
9038 languages: &mut Vec<&'static str>,
9039 include_patterns: &[String],
9040 exclude_patterns: &[String],
9041) -> Result<()> {
9042 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9043 return Ok(());
9044 }
9045
9046 let mut entries = fs::read_dir(dir)
9047 .with_context(|| format!("failed to read directory {}", dir.display()))?
9048 .filter_map(std::result::Result::ok)
9049 .collect::<Vec<_>>();
9050 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9051
9052 for entry in entries {
9053 if budget.shown >= budget.max_entries {
9054 break;
9055 }
9056
9057 let path = entry.path();
9058 let name = entry.file_name().to_string_lossy().into_owned();
9059 let Ok(metadata) = entry.metadata() else {
9060 continue;
9061 };
9062 let row_id = *next_row_id;
9063 *next_row_id += 1;
9064 let modified = metadata
9065 .modified()
9066 .ok()
9067 .map_or_else(|| "-".to_string(), format_system_time);
9068
9069 if metadata.is_dir() {
9070 handle_preview_dir_entry(
9071 root,
9072 &path,
9073 &name,
9074 modified,
9075 depth,
9076 parent_row_id,
9077 row_id,
9078 next_row_id,
9079 budget,
9080 stats,
9081 rows,
9082 languages,
9083 include_patterns,
9084 exclude_patterns,
9085 )?;
9086 continue;
9087 }
9088
9089 if metadata.is_file() {
9090 handle_preview_file_entry(
9091 root,
9092 &path,
9093 &name,
9094 modified,
9095 depth,
9096 parent_row_id,
9097 row_id,
9098 budget,
9099 stats,
9100 rows,
9101 languages,
9102 include_patterns,
9103 exclude_patterns,
9104 );
9105 }
9106 }
9107
9108 Ok(())
9109}
9110
9111fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9112 if let Some(language) = language {
9113 return format!("{language} source");
9114 }
9115 let lower = name.to_ascii_lowercase();
9116 let ext = Path::new(&lower)
9117 .extension()
9118 .and_then(|e| e.to_str())
9119 .unwrap_or("");
9120 match kind {
9121 PreviewKind::Skipped => {
9122 if lower.ends_with(".min.js") {
9123 "Minified asset".to_string()
9124 } else if [
9125 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9126 ]
9127 .contains(&ext)
9128 {
9129 "Binary or archive".to_string()
9130 } else {
9131 "Skipped file".to_string()
9132 }
9133 }
9134 PreviewKind::Unsupported => {
9135 if ext.is_empty() {
9136 "Unsupported file".to_string()
9137 } else {
9138 format!("{} file", ext.to_ascii_uppercase())
9139 }
9140 }
9141 PreviewKind::Supported => "Supported source".to_string(),
9142 PreviewKind::Dir => "Directory".to_string(),
9143 }
9144}
9145
9146fn format_system_time(time: SystemTime) -> String {
9147 #[allow(clippy::cast_possible_wrap)]
9148 let secs = match time.duration_since(UNIX_EPOCH) {
9149 Ok(duration) => duration.as_secs() as i64,
9150 Err(_) => return "-".to_string(),
9151 };
9152 let days = secs.div_euclid(86_400);
9153 let secs_of_day = secs.rem_euclid(86_400);
9154 let (year, month, day) = civil_from_days(days);
9155 let hour = secs_of_day / 3_600;
9156 let minute = (secs_of_day % 3_600) / 60;
9157 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9158}
9159
9160#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9161fn civil_from_days(days: i64) -> (i32, u32, u32) {
9162 let z = days + 719_468;
9163 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9164 let doe = z - era * 146_097;
9165 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9166 let y = yoe + era * 400;
9167 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9168 let mp = (5 * doy + 2) / 153;
9169 let d = doy - (153 * mp + 2) / 5 + 1;
9170 let m = mp + if mp < 10 { 3 } else { -9 };
9171 let year = y + i64::from(m <= 2);
9172 (year as i32, m as u32, d as u32)
9173}
9174
9175#[allow(clippy::case_sensitive_file_extension_comparisons)]
9178fn detect_language_name(name: &str) -> Option<&'static str> {
9179 let lower = name.to_ascii_lowercase();
9180 if lower.ends_with(".c") || lower.ends_with(".h") {
9181 Some("C")
9182 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9183 .iter()
9184 .any(|s| lower.ends_with(s))
9185 {
9186 Some("C++")
9187 } else if lower.ends_with(".cs") {
9188 Some("C#")
9189 } else if lower.ends_with(".py") {
9190 Some("Python")
9191 } else if lower.ends_with(".sh") {
9192 Some("Shell")
9193 } else if [".ps1", ".psm1", ".psd1"]
9194 .iter()
9195 .any(|s| lower.ends_with(s))
9196 {
9197 Some("PowerShell")
9198 } else {
9199 None
9200 }
9201}
9202
9203fn language_icon_file(language: &str) -> Option<&'static str> {
9204 match language {
9205 "C" => Some("c.png"),
9206 "C++" => Some("cpp.png"),
9207 "C#" => Some("c-sharp.png"),
9208 "Python" => Some("python.png"),
9209 "Shell" => Some("shell.png"),
9210 "PowerShell" => Some("powershell.png"),
9211 "JavaScript" => Some("java-script.png"),
9212 "HTML" => Some("html-5.png"),
9213 "Java" => Some("java.png"),
9214 "Visual Basic" => Some("visual-basic.png"),
9215 "Assembly" => Some("asm.png"),
9216 "Go" => Some("go.png"),
9217 "R" => Some("r.png"),
9218 "XML" => Some("xml.png"),
9219 "Groovy" => Some("groovy.png"),
9220 "Dockerfile" => Some("docker.png"),
9221 "Makefile" => Some("makefile.svg"),
9222 "Perl" => Some("perl.svg"),
9223 _ => None,
9224 }
9225}
9226
9227fn language_inline_svg(language: &str) -> Option<&'static str> {
9232 match language {
9233 "Rust" => Some(
9234 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>"##,
9235 ),
9236 "TypeScript" => Some(
9237 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>"##,
9238 ),
9239 _ => None,
9240 }
9241}
9242
9243#[allow(clippy::case_sensitive_file_extension_comparisons)]
9246fn classify_preview_file(name: &str) -> PreviewKind {
9247 let lower = name.to_ascii_lowercase();
9248
9249 let scannable = [
9250 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9251 ".psm1", ".psd1",
9252 ]
9253 .iter()
9254 .any(|suffix| lower.ends_with(suffix));
9255
9256 if scannable {
9257 PreviewKind::Supported
9258 } else if lower.ends_with(".min.js")
9259 || lower.ends_with(".lock")
9260 || lower.ends_with(".png")
9261 || lower.ends_with(".jpg")
9262 || lower.ends_with(".jpeg")
9263 || lower.ends_with(".gif")
9264 || lower.ends_with(".zip")
9265 || lower.ends_with(".pdf")
9266 || lower.ends_with(".pyc")
9267 || lower.ends_with(".xz")
9268 || lower.ends_with(".tar")
9269 || lower.ends_with(".gz")
9270 {
9271 PreviewKind::Skipped
9272 } else {
9273 PreviewKind::Unsupported
9274 }
9275}
9276
9277fn preview_relative_path(root: &Path, path: &Path) -> String {
9278 path.strip_prefix(root)
9279 .ok()
9280 .unwrap_or(path)
9281 .to_string_lossy()
9282 .replace('\\', "/")
9283 .trim_matches('/')
9284 .to_string()
9285}
9286
9287fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9288 if relative.is_empty() {
9289 return false;
9290 }
9291
9292 exclude_patterns.iter().any(|pattern| {
9293 wildcard_match(pattern, relative)
9294 || wildcard_match(pattern, &format!("{relative}/"))
9295 || wildcard_match(pattern, &format!("{relative}/placeholder"))
9296 })
9297}
9298
9299fn should_include_preview_file(
9300 relative: &str,
9301 include_patterns: &[String],
9302 exclude_patterns: &[String],
9303) -> bool {
9304 if relative.is_empty() {
9305 return true;
9306 }
9307
9308 let included = include_patterns.is_empty()
9309 || include_patterns
9310 .iter()
9311 .any(|pattern| wildcard_match(pattern, relative));
9312 let excluded = exclude_patterns
9313 .iter()
9314 .any(|pattern| wildcard_match(pattern, relative));
9315
9316 included && !excluded
9317}
9318
9319fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9320 let pattern = pattern.trim().replace('\\', "/");
9321 let candidate = candidate.trim().replace('\\', "/");
9322 let p = pattern.as_bytes();
9323 let c = candidate.as_bytes();
9324 let mut pi = 0usize;
9325 let mut ci = 0usize;
9326 let mut star: Option<usize> = None;
9327 let mut star_match = 0usize;
9328
9329 while ci < c.len() {
9330 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9331 pi += 1;
9332 ci += 1;
9333 } else if pi < p.len() && p[pi] == b'*' {
9334 while pi < p.len() && p[pi] == b'*' {
9335 pi += 1;
9336 }
9337 star = Some(pi);
9338 star_match = ci;
9339 } else if let Some(star_pi) = star {
9340 star_match += 1;
9341 ci = star_match;
9342 pi = star_pi;
9343 } else {
9344 return false;
9345 }
9346 }
9347
9348 while pi < p.len() && p[pi] == b'*' {
9349 pi += 1;
9350 }
9351
9352 pi == p.len()
9353}
9354
9355fn escape_html(value: &str) -> String {
9356 value
9357 .replace('&', "&")
9358 .replace('<', "<")
9359 .replace('>', ">")
9360 .replace('"', """)
9361 .replace('\'', "'")
9362}
9363
9364#[derive(Clone)]
9365struct SubmoduleRow {
9366 name: String,
9367 relative_path: String,
9368 files_analyzed: u64,
9369 code_lines: u64,
9370 comment_lines: u64,
9371 blank_lines: u64,
9372 total_physical_lines: u64,
9373 html_url: Option<String>,
9374}
9375
9376#[derive(Template)]
9377#[template(
9378 source = r##"
9379<!doctype html>
9380<html lang="en">
9381<head>
9382 <meta charset="utf-8">
9383 <title>OxideSLOC | tmp-sloc</title>
9384 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9385 <style nonce="{{ csp_nonce }}">
9386 :root {
9387 --bg: #efe9e2;
9388 --surface: #fcfaf7;
9389 --surface-2: #f7f0e8;
9390 --surface-3: #efe3d5;
9391 --line: #dfcfbf;
9392 --line-strong: #cfb29c;
9393 --text: #2f241c;
9394 --muted: #6f6257;
9395 --muted-2: #917f71;
9396 --nav: #b85d33;
9397 --nav-2: #7a371b;
9398 --accent: #2563eb;
9399 --accent-2: #1d4ed8;
9400 --oxide: #b85d33;
9401 --oxide-2: #8f4220;
9402 --success-bg: #eaf9ee;
9403 --success-text: #1c8746;
9404 --warn-bg: #fff2d8;
9405 --warn-text: #926000;
9406 --danger-bg: #fdeaea;
9407 --danger-text: #b33b3b;
9408 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9409 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9410 --radius: 14px;
9411 }
9412
9413 body.dark-theme {
9414 --bg: #1b1511;
9415 --surface: #261c17;
9416 --surface-2: #2d221d;
9417 --surface-3: #372922;
9418 --line: #524238;
9419 --line-strong: #6c5649;
9420 --text: #f5ece6;
9421 --muted: #c7b7aa;
9422 --muted-2: #aa9485;
9423 --nav: #b85d33;
9424 --nav-2: #7a371b;
9425 --accent: #6f9bff;
9426 --accent-2: #4a78ee;
9427 --oxide: #d37a4c;
9428 --oxide-2: #b35428;
9429 --success-bg: #163927;
9430 --success-text: #8fe2a8;
9431 --warn-bg: #3c2d11;
9432 --warn-text: #f3cb75;
9433 --danger-bg: #3d1f1f;
9434 --danger-text: #ff9f9f;
9435 --shadow: 0 14px 28px rgba(0,0,0,0.28);
9436 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9437 }
9438
9439 * { box-sizing: border-box; }
9440 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); }
9441 html { overflow-y: scroll; }
9442 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9443 .top-nav, .page, .loading { position: relative; z-index: 2; }
9444 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9445 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9446 .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); }
9447 .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; }
9448 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9449 .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)); }
9450 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9451 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9452 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9453 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9454 .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; }
9455 .nav-project-pill.visible { display:inline-flex; }
9456 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9457 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9458 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9459 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9460 @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; } }
9461 .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; }
9462 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9463 .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; }
9464 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9465 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9466 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9467 .theme-toggle .icon-sun { display:none; }
9468 body.dark-theme .theme-toggle .icon-sun { display:block; }
9469 body.dark-theme .theme-toggle .icon-moon { display:none; }
9470 .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;}
9471 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9472 .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);}
9473 .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;}
9474 .settings-close:hover{color:var(--text);background:var(--surface-2);}
9475 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9476 .settings-modal-body{padding:14px 16px 16px;}
9477 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9478 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9479 .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;}
9480 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9481 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9482 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9483 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9484 .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;}
9485 .tz-select:focus{border-color:var(--oxide);}
9486 .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; }
9487 .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;}
9488 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9489 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9490 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9491 .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; }
9492 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9493 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9494 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9495 .wb-stats-header { padding: 10px 24px 0; }
9496 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9497 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9498 .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; }
9499 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9500 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9501 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9502 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9503 .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; }
9504 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9505 .ws-stat-analyzers { position: relative; }
9506 .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; }
9507 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9508 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9509 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9510 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9511 .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; }
9512 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9513 .ws-divider { display: none; }
9514 .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%; }
9515 .ws-path-link:hover { color:var(--oxide); }
9516 body.dark-theme .ws-path-link { color:var(--oxide); }
9517 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9518 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9519 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9520 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9521 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9522 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9523 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9524 .ws-mini-box-lg { flex:2 1 0; }
9525 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9526 .ws-mini-box-br { flex:1.5 1 0; }
9527 .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); }
9528 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9529 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9530 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9531 .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; }
9532 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9533 .git-source-banner strong { font-weight:800; color:var(--text); }
9534 .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; }
9535 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9536 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9537 .git-source-banner a:hover { text-decoration:underline; }
9538 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9539 .path-scope-sep { background:var(--line); margin:4px 14px; }
9540 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9541 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9542 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9543 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9544 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9545 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9546 .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; }
9547 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9548 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9549 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9550 .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; }
9551 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9552 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9553 [data-wb-tip] { cursor:help; }
9554 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9555 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9556 .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; }
9557 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9558 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9559 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9560 .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; }
9561 .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); }
9562 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9563 .side-info-card { padding: 18px; }
9564 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9565 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9566 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9567 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9568 .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); }
9569 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9570 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9571 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9572 .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; }
9573 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9574 .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; }
9575 .side-stack::-webkit-scrollbar { display: none; }
9576 .step-nav { padding: 20px 16px; }
9577 .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); }
9578 .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; }
9579 .step-button:hover { background: var(--surface-2); }
9580 .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); }
9581 .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; }
9582 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9583 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9584 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9585 .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); }
9586 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9587 .step-nav-sum-row:last-child { border-bottom:none; }
9588 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9589 .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; }
9590 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9591 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9592 .quick-scan-section { padding: 10px 4px 14px; }
9593 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9594 .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; }
9595 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9596 .quick-scan-btn:active { transform:translateY(0); }
9597 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9598 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9599 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9600 @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);} }
9601 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9602 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9603 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9604 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9605 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9606 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9607 .step-button.done .step-check { opacity:1; }
9608 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9609 .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; }
9610 .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; }
9611 .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; }
9612 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9613 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9614 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9615 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9616 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9617 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9618 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9619 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9620 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9621 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9622 .card-body { padding: 22px; }
9623 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9624 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9625 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9626 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9627 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9628 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9629 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9630 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9631 .field { min-width:0; }
9632 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9633 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; }
9634 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); }
9635 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9636 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); }
9637 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9638 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9639 .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; }
9640 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9641 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9642 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9643 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9644 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9645 .input-group.compact { grid-template-columns: 1fr auto auto; }
9646 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9647 .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)); }
9648 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9649 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9650 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9651 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9652 .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; }
9653 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9654 .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; }
9655 .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); }
9656 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9657 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9658 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9659 button.secondary { background: var(--surface); }
9660 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9661 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9662 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9663 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9664 .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); }
9665 .section + .wizard-actions { border-top: none; padding-top: 0; }
9666 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9667 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9668 .field-help-grid.coupled-help { margin-top: 12px; }
9669 .field-help-grid.preset-grid { align-items: start; }
9670 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9671 .preset-inline-row .field { margin: 0; }
9672 .preset-inline-row .explainer-card { margin: 0; }
9673 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9674 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9675 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9676 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9677 .preset-kv-row > :last-child { flex:1; min-width:0; }
9678 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9679 .output-field-row .field { margin: 0; }
9680 .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; }
9681 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9682 .step3-subtitle { margin-bottom: 10px; max-width: none; }
9683 .counting-intro { margin-bottom: 8px; max-width: none; }
9684 .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; }
9685 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9686 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9687 .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; }
9688 .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; }
9689 .section-spacer-top { margin-top: 28px; }
9690 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9691 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9692 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9693 .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); }
9694 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9695 .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; }
9696 .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; }
9697 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9698 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9699 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9700 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9701 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9702 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9703 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9704 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9705 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9706 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9707 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9708 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9709 .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); }
9710 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9711 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9712 .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; }
9713 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9714 .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; }
9715 .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; }
9716 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9717 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9718 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9719 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9720 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9721 .advanced-rule-description strong { color: var(--text); }
9722 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9723 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9724 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9725 .review-link:hover { text-decoration: underline; }
9726 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9727 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9728 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9729 .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; }
9730 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9731 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9732 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9733 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9734 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9735 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9736 .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; }
9737 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9738 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9739 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9740 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9741 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9742 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9743 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9744 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9745 .review-card ul { padding-left: 18px; margin: 0; }
9746 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9747 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9748 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9749 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9750 .review-card { min-height: 200px; }
9751 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9752 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9753 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9754 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9755 .lang-overflow-chip { position:relative; cursor:default; }
9756 .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; }
9757 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9758 .git-inline-row { align-items:start; }
9759 .mixed-line-card { display:flex; flex-direction:column; }
9760 .preset-inline-row .toggle-card { justify-content: center; }
9761 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9762 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9763 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9764 .explorer-title { font-size: 18px; font-weight: 850; }
9765 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9766 .explorer-subtitle.wide { max-width: none; }
9767 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9768 .better-spacing { align-items:flex-start; justify-content:flex-end; }
9769 .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; }
9770 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9771 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9772 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9773 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9774 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9775 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9776 .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; }
9777 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
9778 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
9779 .scope-stat-button.supported { background: var(--success-bg); }
9780 .scope-stat-button.skipped { background: var(--warn-bg); }
9781 .scope-stat-button.unsupported { background: var(--danger-bg); }
9782 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
9783 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
9784 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
9785 [data-tooltip] { position: relative; }
9786 [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); }
9787 [data-tooltip]:hover::after { display: block; }
9788 .scope-stat-button[data-tooltip] { cursor: pointer; }
9789 .badge[data-tooltip] { cursor: help; }
9790 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
9791 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
9792 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
9793 .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; }
9794 .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; }
9795 code { display:inline-block; margin-top:0; padding:2px 7px; }
9796 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9797 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
9798 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
9799 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
9800 .language-pill.muted-pill { color: var(--muted); }
9801 button.language-pill { appearance:none; cursor:pointer; }
9802 .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); }
9803 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
9804 .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; }
9805 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
9806 .file-explorer-search-row { margin-left: auto; }
9807 .explorer-filter-select { min-width: 170px; width: 170px; }
9808 .explorer-search { min-width: 300px; width: 300px; }
9809 .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); }
9810 .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; }
9811 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
9812 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
9813 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
9814 .file-explorer-tree { max-height: 640px; overflow:auto; }
9815 .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); }
9816 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
9817 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
9818 .tree-row.hidden-by-filter { display:none !important; }
9819 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
9820 .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; }
9821 .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; }
9822 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
9823 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
9824 .tree-node { display:inline-flex; align-items:center; min-width:0; }
9825 .tree-node-dir { color: var(--text); font-weight: 800; }
9826 .tree-node-supported { color: var(--success-text); }
9827 .tree-node-skipped { color: var(--warn-text); }
9828 .tree-node-unsupported { color: var(--danger-text); }
9829 .tree-node-more { color: var(--muted-2); font-style: italic; }
9830 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
9831 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
9832 .tree-status-cell { display:flex; justify-content:flex-start; }
9833 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
9834 .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; }
9835 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
9836 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
9837 .cov-scan-idle { display:none; }
9838 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
9839 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
9840 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
9841 .cov-scan-title { font-weight:600; font-size:12.5px; }
9842 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
9843 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
9844 .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; }
9845 .cov-scan-use:hover { opacity:.75; }
9846 .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; }
9847 .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; }
9848 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
9849 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
9850 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
9851 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
9852 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
9853 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
9854 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
9855 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
9856 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
9857 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
9858 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
9859 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
9860 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
9861 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
9862 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
9863 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
9864 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
9865 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
9866 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
9867 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
9868 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
9869 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
9870 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
9871 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
9872 .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); }
9873 .loading.active { display:flex; }
9874 .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; }
9875 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
9876 .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; }
9877 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
9878 .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; }
9879 .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; }
9880 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
9881 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
9882 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
9883 .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; }
9884 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
9885 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
9886 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
9887 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
9888 .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; }
9889 .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; }
9890 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
9891 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
9892 .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; }
9893 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
9894 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
9895 .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; }
9896 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
9897 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
9898 .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; }
9899 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
9900 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
9901 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
9902 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
9903 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
9904 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
9905 .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; }
9906 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
9907 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
9908 .hidden { display:none !important; }
9909 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9910 .site-footer a{color:var(--muted);}
9911 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
9912 @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; } }
9913 .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;}
9914 @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));}}
9915 .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;}
9916 .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; }
9917 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
9918 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
9919 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
9920 .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; }
9921 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
9922 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
9923 .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; }
9924 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
9925 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
9926 .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; }
9927 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
9928 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
9929 .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; }
9930 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
9931 .info-icon-btn:hover { color:var(--text); }
9932 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); }
9933 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
9934 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
9935 </style>
9936</head>
9937<body>
9938 <div class="background-watermarks" aria-hidden="true">
9939 <img src="/images/logo/logo-text.png" alt="" />
9940 <img src="/images/logo/logo-text.png" alt="" />
9941 <img src="/images/logo/logo-text.png" alt="" />
9942 <img src="/images/logo/logo-text.png" alt="" />
9943 <img src="/images/logo/logo-text.png" alt="" />
9944 <img src="/images/logo/logo-text.png" alt="" />
9945 <img src="/images/logo/logo-text.png" alt="" />
9946 <img src="/images/logo/logo-text.png" alt="" />
9947 <img src="/images/logo/logo-text.png" alt="" />
9948 <img src="/images/logo/logo-text.png" alt="" />
9949 <img src="/images/logo/logo-text.png" alt="" />
9950 <img src="/images/logo/logo-text.png" alt="" />
9951 <img src="/images/logo/logo-text.png" alt="" />
9952 <img src="/images/logo/logo-text.png" alt="" />
9953 </div>
9954 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9955 <div class="top-nav">
9956 <div class="top-nav-inner">
9957 <a class="brand" href="/">
9958 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
9959 <div class="brand-copy">
9960 <div class="brand-title">OxideSLOC</div>
9961 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
9962 </div>
9963 </a>
9964 <div class="nav-project-slot">
9965 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
9966 <span class="nav-project-label">Project</span>
9967 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
9968 </div>
9969 </div>
9970 <div class="nav-status">
9971 <a class="nav-pill" href="/">Home</a>
9972 <div class="nav-dropdown">
9973 <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>
9974 <div class="nav-dropdown-menu">
9975 <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>
9976 </div>
9977 </div>
9978 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9979 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9980 <div class="nav-dropdown">
9981 <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>
9982 <div class="nav-dropdown-menu">
9983 <a href="/webhook-setup"><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>
9984 </div>
9985 </div>
9986 <div class="server-status-wrap">
9987 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
9988 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
9989 </div>
9990 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9991 <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>
9992 </button>
9993 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
9994 <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>
9995 <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>
9996 </button>
9997 </div>
9998 </div>
9999 </div>
10000
10001 <div class="loading" id="loading">
10002 <div class="loading-card">
10003 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10004 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10005 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10006 <div class="lc-path" id="lc-path"></div>
10007 <div class="lc-metrics" id="lc-metrics">
10008 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10009 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10010 </div>
10011 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10012 <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>
10013 <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>
10014 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10015 <div class="lc-actions hidden" id="lc-actions">
10016 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10017 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10018 </div>
10019 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10020 <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>
10021 Cancel scan
10022 </button>
10023 </div>
10024 </div>
10025
10026 <div class="page">
10027 <div class="workbench-strip">
10028 <div class="workbench-box wb-stats">
10029 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10030 <span class="wb-stats-title">Analysis session</span>
10031 </div>
10032 <div class="ws-left">
10033 <div class="ws-stat ws-stat-analyzers">
10034 <span class="ws-label">Analyzers</span>
10035 <span class="ws-value">
10036 <span class="ws-badge">41 languages</span>
10037 </span>
10038 <div class="ws-lang-tooltip">
10039 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10040 <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>
10041 <div class="ws-lang-grid">
10042 <span class="ws-lang-item">Assembly</span>
10043 <span class="ws-lang-item">C</span>
10044 <span class="ws-lang-item">C++</span>
10045 <span class="ws-lang-item">C#</span>
10046 <span class="ws-lang-item">Clojure</span>
10047 <span class="ws-lang-item">CSS</span>
10048 <span class="ws-lang-item">Dart</span>
10049 <span class="ws-lang-item">Dockerfile</span>
10050 <span class="ws-lang-item">Elixir</span>
10051 <span class="ws-lang-item">Erlang</span>
10052 <span class="ws-lang-item">F#</span>
10053 <span class="ws-lang-item">Go</span>
10054 <span class="ws-lang-item">Groovy</span>
10055 <span class="ws-lang-item">Haskell</span>
10056 <span class="ws-lang-item">HTML</span>
10057 <span class="ws-lang-item">Java</span>
10058 <span class="ws-lang-item">JavaScript</span>
10059 <span class="ws-lang-item">Julia</span>
10060 <span class="ws-lang-item">Kotlin</span>
10061 <span class="ws-lang-item">Lua</span>
10062 <span class="ws-lang-item">Makefile</span>
10063 <span class="ws-lang-item">Nim</span>
10064 <span class="ws-lang-item">Obj-C</span>
10065 <span class="ws-lang-item">OCaml</span>
10066 <span class="ws-lang-item">Perl</span>
10067 <span class="ws-lang-item">PHP</span>
10068 <span class="ws-lang-item">PowerShell</span>
10069 <span class="ws-lang-item">Python</span>
10070 <span class="ws-lang-item">R</span>
10071 <span class="ws-lang-item">Ruby</span>
10072 <span class="ws-lang-item">Rust</span>
10073 <span class="ws-lang-item">Scala</span>
10074 <span class="ws-lang-item">SCSS</span>
10075 <span class="ws-lang-item">Shell</span>
10076 <span class="ws-lang-item">SQL</span>
10077 <span class="ws-lang-item">Svelte</span>
10078 <span class="ws-lang-item">Swift</span>
10079 <span class="ws-lang-item">TypeScript</span>
10080 <span class="ws-lang-item">Vue</span>
10081 <span class="ws-lang-item">XML</span>
10082 <span class="ws-lang-item">Zig</span>
10083 </div>
10084 </div>
10085 </div>
10086 <div class="ws-divider"></div>
10087 <div class="ws-stat" data-wb-tip="Localhost mode — all scans run on this machine against local file system paths."><span class="ws-label">Mode</span><span class="ws-value">Localhost</span></div>
10088 <div class="ws-divider"></div>
10089 <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>
10090 <div class="ws-divider"></div>
10091 <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.">
10092 <span class="ws-label">Output</span>
10093 <span class="ws-value">
10094 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10095 <span id="ws-output-root">project/sloc</span>
10096 </button>
10097 </span>
10098 </div>
10099 </div>
10100 </div>
10101 <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.">
10102 <div class="ws-history-label">Scan history</div>
10103 <div class="ws-history-inner">
10104 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10105 <div class="ws-mini-label">Scans</div>
10106 <div class="ws-mini-value" id="ws-scan-count">—</div>
10107 </div>
10108 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10109 <div class="ws-mini-label">Last Scan</div>
10110 <div class="ws-mini-value" id="ws-last-scan">—</div>
10111 </div>
10112 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10113 <div class="ws-mini-label">Branch</div>
10114 <div class="ws-mini-value" id="ws-branch">—</div>
10115 </div>
10116 </div>
10117 </div>
10118 </div>
10119
10120 <div class="layout">
10121 <aside class="side-stack">
10122 <section class="step-nav">
10123 <h3>Guided scan setup</h3>
10124 <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>
10125 <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>
10126 <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>
10127 <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>
10128
10129 <div class="step-steps-divider"></div>
10130
10131 <div class="step-nav-info" id="step-nav-info">
10132 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10133 <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>
10134 </div>
10135
10136 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10137 <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>
10138 <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>
10139 <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>
10140 </div>
10141
10142 <div class="quick-scan-divider"></div>
10143 <div class="quick-scan-section">
10144 <div class="quick-scan-label">No customization needed?</div>
10145 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10146 <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>
10147 Quick Scan
10148 </button>
10149 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10150 </div>
10151
10152 <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>
10153 </section>
10154
10155 </aside>
10156
10157 <section class="card">
10158 <div class="card-header">
10159 <div class="card-title-row">
10160 <div>
10161 <h1 class="card-title">Guided scan configuration</h1>
10162 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10163 </div>
10164 <div class="wizard-progress" aria-label="Scan setup progress">
10165 <div class="wizard-progress-top">
10166 <span class="wizard-progress-label">Setup progress</span>
10167 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10168 </div>
10169 <div class="wizard-progress-track">
10170 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10171 </div>
10172 </div>
10173 </div>
10174 </div>
10175 <div class="card-body">
10176 <form method="post" action="/analyze" id="analyze-form">
10177 <div class="wizard-step active" data-step="1">
10178 <div class="section">
10179 <div class="section-kicker">Step 1</div>
10180 <h2>Select project and preview scope</h2>
10181 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10182 <div class="field">
10183 <label for="path">Project path</label>
10184 {% if !git_repo.is_empty() %}
10185 <div class="git-source-banner">
10186 <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>
10187 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10188 <a href="/git-browser">← Back to Git Browser</a>
10189 </div>
10190 {% endif %}
10191 <div class="path-scope-grid">
10192 {% if !git_repo.is_empty() %}
10193 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10194 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10195 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10196 {% else %}
10197 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10198 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10199 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10200 {% endif %}
10201 <div class="path-scope-sep"></div>
10202 <div class="scope-legend-row">
10203 <span class="scope-legend-label">Scope legend:</span>
10204 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10205 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10206 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10207 </div>
10208 </div>
10209 {% if git_repo.is_empty() %}
10210 <div class="path-info-row">
10211 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10212 <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>
10213 <span id="project-size-text">Project size: —</span>
10214 </button>
10215 </div>
10216 {% else %}
10217 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10218 {% endif %}
10219 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10220 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10221 </div>
10222
10223 <div class="scope-preview-divider" aria-hidden="true"></div>
10224
10225 <div id="preview-panel">
10226 <div class="preview-error">Loading preview...</div>
10227 </div>
10228 </div>
10229
10230 <div class="section" style="margin-top:14px;">
10231 <div class="preset-inline-row git-inline-row">
10232 <div class="toggle-card" style="margin:0;">
10233 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10234 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10235 <label class="checkbox">
10236 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10237 <div>
10238 <span>Detect and separate git submodules</span>
10239 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10240 </div>
10241 </label>
10242 </div>
10243 <div class="explainer-card prominent" style="margin:0;">
10244 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10245 <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>
10246 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10247 path = libs/core
10248 url = https://github.com/org/core.git
10249
10250[submodule "libs/ui"]
10251 path = libs/ui
10252 url = https://github.com/org/ui.git</div>
10253 </div>
10254 </div>
10255 </div>
10256
10257 <div class="section">
10258 <div class="field-grid">
10259 <div class="field">
10260 <label for="include_globs">Include globs</label>
10261 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
10262 <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>
10263 </div>
10264 <div class="field">
10265 <label for="exclude_globs">Exclude globs</label>
10266 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
10267 <div id="quick-exclude-chips" class="quick-excl-row">
10268 <span class="quick-excl-label">Quick add:</span>
10269 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10270 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10271 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10272 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10273 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10274 <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>
10275 </div>
10276 <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>
10277 </div>
10278 </div>
10279 <div class="glob-guidance-grid">
10280 <div class="glob-guidance-card">
10281 <strong>How to read them</strong>
10282 <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>
10283 </div>
10284 <div class="glob-guidance-card">
10285 <strong>Common include examples</strong>
10286 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10287 </div>
10288 <div class="glob-guidance-card">
10289 <strong>Common exclude examples</strong>
10290 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10291 </div>
10292 </div>
10293 </div>
10294
10295 <div class="section" style="margin-top:14px;">
10296 <div class="preset-inline-row git-inline-row">
10297 <div class="toggle-card" style="margin:0;">
10298 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10299 <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>
10300 <div class="field" style="margin:0;">
10301 <div class="input-group compact">
10302 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10303 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10304 </div>
10305 <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>
10306 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10307 </div>
10308 </div>
10309 <div class="explainer-card prominent" style="margin:0;">
10310 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10311 <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>
10312 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10313lcov --capture --directory . --output-file coverage/lcov.info
10314
10315# C / C++ — llvm-cov (LCOV)
10316llvm-profdata merge -sparse default.profraw -o default.profdata
10317llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10318
10319# C# — coverlet (Cobertura XML)
10320dotnet test --collect:"XPlat Code Coverage"
10321
10322# Python — pytest-cov (Cobertura XML)
10323pytest --cov --cov-report=xml
10324
10325# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10326./gradlew jacocoTestReport</div>
10327 </div>
10328 </div>
10329 </div>
10330
10331 <div class="wizard-actions">
10332 <div class="left"></div>
10333 <div class="right">
10334 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10335 </div>
10336 </div>
10337 </div>
10338
10339 <div class="wizard-step" data-step="2">
10340 <div class="section">
10341 <div class="section-kicker">Step 2</div>
10342 <h2>Choose counting behavior</h2>
10343 <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>
10344 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10345 <div class="subsection-bar">Primary line classification</div>
10346 <div class="preset-kv-row">
10347 <div class="toggle-card mixed-line-card" style="margin:0;">
10348 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10349 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10350 <select id="mixed_line_policy" name="mixed_line_policy">
10351 <option value="code_only">Code only</option>
10352 <option value="code_and_comment">Code and comment</option>
10353 <option value="comment_only">Comment only</option>
10354 <option value="separate_mixed_category">Separate mixed category</option>
10355 </select>
10356 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10357 </div>
10358 <div class="explainer-card prominent" style="margin:0;">
10359 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10360 <div class="explainer-body" id="mixed-policy-description"></div>
10361 <div class="code-sample" id="mixed-policy-example"></div>
10362 </div>
10363 </div>
10364 </div>
10365
10366 <div class="subsection-bar">Additional scan rules</div>
10367 <div class="scan-rules-grid">
10368 <div class="preset-inline-row">
10369 <div class="toggle-card" style="margin:0;">
10370 <div class="field-help-title">Generated files</div>
10371 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10372 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10373 </div>
10374 <div class="explainer-card prominent" style="margin:0;">
10375 <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>
10376 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10377# Files matching codegen patterns are excluded:
10378# *.generated.cs *.pb.go *.g.dart</div>
10379 </div>
10380 </div>
10381 <div class="preset-inline-row">
10382 <div class="toggle-card" style="margin:0;">
10383 <div class="field-help-title">Minified files</div>
10384 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10385 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10386 </div>
10387 <div class="explainer-card prominent" style="margin:0;">
10388 <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>
10389 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10390# Heuristic: very long lines + low whitespace ratio
10391# jquery.min.js bundle.min.css → skipped</div>
10392 </div>
10393 </div>
10394 <div class="preset-inline-row">
10395 <div class="toggle-card" style="margin:0;">
10396 <div class="field-help-title">Vendor directories</div>
10397 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10398 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10399 </div>
10400 <div class="explainer-card prominent" style="margin:0;">
10401 <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>
10402 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10403# Directories named vendor/ node_modules/ third_party/
10404# → entire subtree is excluded from totals</div>
10405 </div>
10406 </div>
10407 <div class="preset-inline-row">
10408 <div class="toggle-card" style="margin:0;">
10409 <div class="field-help-title">Lockfiles and manifests</div>
10410 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10411 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10412 </div>
10413 <div class="explainer-card prominent" style="margin:0;">
10414 <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>
10415 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
10416# Files like package-lock.json Cargo.lock yarn.lock
10417# → skipped unless this is enabled</div>
10418 </div>
10419 </div>
10420 <div class="preset-inline-row">
10421 <div class="toggle-card" style="margin:0;">
10422 <div class="field-help-title">Binary handling</div>
10423 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10424 <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>
10425 </div>
10426 <div class="explainer-card prominent" style="margin:0;">
10427 <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>
10428 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
10429# Detected via long lines + low whitespace heuristic
10430# .png .exe .so → skipped silently</div>
10431 </div>
10432 </div>
10433 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10434 <div class="toggle-card" style="margin:0;">
10435 <div class="field-help-title">Python docstrings</div>
10436 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10437 <label class="checkbox">
10438 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10439 <span>Count as comment-style lines</span>
10440 </label>
10441 </div>
10442 <div class="explainer-card prominent" style="margin:0;">
10443 <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>
10444 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10445 </div>
10446 </div>
10447 </div>
10448 <div class="always-tracked-tip">
10449 <div class="always-tracked-tip-icon">ℹ</div>
10450 <div class="always-tracked-tip-body">
10451 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
10452 <h4>Comment and blank-line basics & Lines on the boundary</h4>
10453 <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>
10454 </div>
10455 </div>
10456
10457 <div class="wizard-actions">
10458 <div class="left">
10459 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10460 </div>
10461 <div class="right">
10462 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10463 </div>
10464 </div>
10465 </div>
10466
10467 <div class="wizard-step" data-step="3">
10468 <div class="section">
10469 <div class="section-kicker">Step 3</div>
10470 <h2>Output and report identity</h2>
10471 <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>
10472 <div class="preset-kv-row">
10473 <div class="toggle-card" style="margin:0;">
10474 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10475 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10476 <select id="scan_preset">
10477 <option value="balanced">Balanced local scan</option>
10478 <option value="code_focused">Code focused</option>
10479 <option value="comment_audit">Comment audit</option>
10480 <option value="deep_review">Deep review</option>
10481 </select>
10482 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10483 </div>
10484 <div class="explainer-card">
10485 <div class="field-help-title">Selected scan preset</div>
10486 <div class="explainer-body" id="scan-preset-description"></div>
10487 <div class="preset-summary-row" id="scan-preset-summary"></div>
10488 <div class="code-sample" id="scan-preset-example"></div>
10489 <div class="preset-note" id="scan-preset-note"></div>
10490 </div>
10491 </div>
10492 <hr class="step3-separator" />
10493 <div class="preset-kv-row">
10494 <div class="toggle-card" style="margin:0;">
10495 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10496 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10497 <select id="artifact_preset">
10498 <option value="review">Review bundle</option>
10499 <option value="full">Full bundle</option>
10500 <option value="html_only">HTML only</option>
10501 <option value="machine">Machine bundle</option>
10502 </select>
10503 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10504 </div>
10505 <div class="explainer-card">
10506 <div class="field-help-title">Selected artifact preset</div>
10507 <div class="explainer-body" id="artifact-preset-description"></div>
10508 <div class="preset-summary-row" id="artifact-preset-summary"></div>
10509 <div class="code-sample" id="artifact-preset-example"></div>
10510 </div>
10511 </div>
10512 </div>
10513
10514 <div class="section section-spacer-top">
10515 <div class="output-field-row">
10516 <div class="field">
10517 <label for="output_dir">Output directory</label>
10518 <div class="input-group compact">
10519 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10520 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10521 <button type="button" class="mini-button" id="use-default-output">Use default</button>
10522 </div>
10523 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10524 </div>
10525 <div class="output-field-aside">
10526 <strong>Where reports land</strong>
10527 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.
10528 </div>
10529 </div>
10530 </div>
10531
10532 <div class="section section-spacer-top">
10533 <div class="output-field-row">
10534 <div class="field">
10535 <label for="report_title">Report title</label>
10536 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10537 <div class="hint">Appears in HTML and PDF output headers.</div>
10538 </div>
10539 <div class="output-field-aside">
10540 <strong>Shown in exported artifacts</strong>
10541 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.
10542 </div>
10543 </div>
10544 </div>
10545
10546 <div class="section section-spacer-top">
10547 <div class="output-field-row">
10548 <div class="field">
10549 <label for="report_header_footer">Report header / footer</label>
10550 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10551 <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>
10552 </div>
10553 <div class="output-field-aside">
10554 <strong>Page-level identification</strong>
10555 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.
10556 </div>
10557 </div>
10558 </div>
10559
10560 <div class="section">
10561 <div class="section-kicker">Artifacts</div>
10562 <div class="artifact-grid" style="margin-bottom:24px;">
10563 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10564 <div class="marker">✓</div>
10565 <div class="artifact-icon">H</div>
10566 <h4>HTML report</h4>
10567 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10568 <div class="artifact-tags">
10569 <span class="soft-chip">Best for visual review</span>
10570 <span class="soft-chip">Embeddable preview</span>
10571 </div>
10572 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10573 </div>
10574 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10575 <div class="marker">✓</div>
10576 <div class="artifact-icon">P</div>
10577 <h4>PDF export</h4>
10578 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10579 <div class="artifact-tags">
10580 <span class="soft-chip">Portable snapshot</span>
10581 <span class="soft-chip">Good for handoff</span>
10582 </div>
10583 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10584 </div>
10585 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10586 <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>
10587 <div class="marker">✓</div>
10588 <div class="artifact-icon" style="color:var(--muted);">J</div>
10589 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10590 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10591 <div class="artifact-tags">
10592 <span class="soft-chip">Required for compare</span>
10593 <span class="soft-chip">Auto-enabled</span>
10594 </div>
10595 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10596 </div>
10597 </div>
10598 <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>
10599 </div>
10600
10601 <div class="wizard-actions">
10602 <div class="left">
10603 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10604 </div>
10605 <div class="right">
10606 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10607 </div>
10608 </div>
10609 </div>
10610
10611 <div class="wizard-step" data-step="4">
10612 <div class="section">
10613 <div class="section-kicker">Step 4</div>
10614 <h2>Review selections and run</h2>
10615 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10616 <div class="review-grid">
10617 <div class="review-card highlight">
10618 <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>
10619 <ul id="review-scan-summary"></ul>
10620 </div>
10621 <div class="review-card highlight">
10622 <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>
10623 <ul id="review-count-summary"></ul>
10624 </div>
10625 <div class="review-card">
10626 <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>
10627 <ul id="review-artifact-summary"></ul>
10628 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10629 </div>
10630 <div class="review-card">
10631 <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>
10632 <ul id="review-preview-summary"></ul>
10633 </div>
10634 </div>
10635 </div>
10636
10637 <div class="wizard-actions">
10638 <div class="left">
10639 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10640 </div>
10641 <div class="right">
10642 <button type="submit" id="submit-button" class="primary">Run analysis</button>
10643 </div>
10644 </div>
10645 </div></form>
10646 </div>
10647 </section>
10648 </div>
10649 </div>
10650
10651 <script nonce="{{ csp_nonce }}">
10652 (function () {
10653 function startScanPhase() {
10654 var phaseEl = document.getElementById("scan-phase");
10655 if (!phaseEl) return;
10656 var phases = [
10657 "Discovering files...",
10658 "Decoding file encodings...",
10659 "Detecting languages...",
10660 "Analyzing source lines...",
10661 "Applying counting policies...",
10662 "Aggregating results...",
10663 "Rendering report..."
10664 ];
10665 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10666 var i = 0;
10667 function next() {
10668 phaseEl.style.opacity = "0";
10669 setTimeout(function () {
10670 phaseEl.textContent = phases[i];
10671 phaseEl.style.opacity = "0.85";
10672 var delay = durations[i] || 1800;
10673 i++;
10674 if (i < phases.length) { setTimeout(next, delay); }
10675 }, 200);
10676 }
10677 next();
10678 }
10679
10680 var form = document.getElementById("analyze-form");
10681 var loading = document.getElementById("loading");
10682 var submitButton = document.getElementById("submit-button");
10683 var pathInput = document.getElementById("path");
10684 var GIT_MODE = !!(pathInput && pathInput.readOnly);
10685 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10686 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10687 var outputDirInput = document.getElementById("output_dir");
10688 var reportTitleInput = document.getElementById("report_title");
10689 var previewPanel = document.getElementById("preview-panel");
10690 var refreshButton = document.getElementById("refresh-preview");
10691 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10692 var useSamplePath = document.getElementById("use-sample-path");
10693 var useDefaultOutput = document.getElementById("use-default-output");
10694 var browsePath = document.getElementById("browse-path");
10695 var browseOutputDir = document.getElementById("browse-output-dir");
10696 var browseCoverage = document.getElementById("browse-coverage");
10697 var coverageInput = document.getElementById("coverage_file");
10698 var covScanStatus = document.getElementById("cov-scan-status");
10699 var coverageSuggestTimer = null;
10700 var covAutoFilled = false;
10701 var themeToggle = document.getElementById("theme-toggle");
10702 var mixedLinePolicy = document.getElementById("mixed_line_policy");
10703 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10704 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10705 var scanPreset = document.getElementById("scan_preset");
10706 var artifactPreset = document.getElementById("artifact_preset");
10707 var includeGlobsInput = document.getElementById("include_globs");
10708 var excludeGlobsInput = document.getElementById("exclude_globs");
10709
10710 // Quick-exclude chips — append pattern to exclude_globs textarea.
10711 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10712 chip.addEventListener("click", function() {
10713 var pattern = chip.getAttribute("data-pattern") || "";
10714 if (!pattern || !excludeGlobsInput) return;
10715 var current = excludeGlobsInput.value.trim();
10716 // For the "skip all" chip, replace any existing dep patterns cleanly.
10717 var patterns = pattern.split("\n");
10718 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10719 var added = false;
10720 patterns.forEach(function(p) {
10721 p = p.trim();
10722 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10723 });
10724 if (added) {
10725 excludeGlobsInput.value = lines.join("\n");
10726 excludeGlobsInput.dispatchEvent(new Event("input"));
10727 }
10728 chip.classList.add("active");
10729 });
10730 });
10731
10732 var liveReportTitle = document.getElementById("live-report-title");
10733 var navProjectPill = document.getElementById("nav-project-pill");
10734 var navProjectTitle = document.getElementById("nav-project-title");
10735 var reportTitlePreview = null;
10736 var wizardProgressFill = document.getElementById("wizard-progress-fill");
10737 var wizardProgressValue = document.getElementById("wizard-progress-value");
10738 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10739 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10740 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10741 var reportTitleTouched = false;
10742 var currentStep = 1;
10743 var previewTimer = null;
10744 var quickScanBtn = document.getElementById("quick-scan-btn");
10745
10746 function dismissAnalysisModal() {
10747 if (loading) loading.classList.remove("active");
10748 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10749 var el = document.getElementById(id);
10750 if (el) el.classList.add("hidden");
10751 });
10752 var cancelBtn = document.getElementById("lc-cancel-btn");
10753 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10754 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10755 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10756 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10757 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10758 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10759 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10760 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10761 }
10762
10763 var lcDismissBtn = document.getElementById("lc-dismiss");
10764 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10765
10766 function startAsyncAnalysis(formData) {
10767 var gitRepo = (formData.get("git_repo") || "").toString();
10768 var gitRef = (formData.get("git_ref") || "").toString();
10769 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10770 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10771
10772 var pathEl = document.getElementById("lc-path");
10773 if (pathEl) pathEl.textContent = displayPath;
10774
10775 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10776 var el = document.getElementById(id);
10777 if (el) el.classList.add("hidden");
10778 });
10779 var cancelBtn = document.getElementById("lc-cancel-btn");
10780 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
10781 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10782 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10783 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10784 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
10785 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
10786
10787 if (loading) loading.classList.add("active");
10788
10789 var startTime = Date.now();
10790 var elapsedTimer = setInterval(function() {
10791 var s = Math.floor((Date.now() - startTime) / 1000);
10792 var el = document.getElementById("lc-elapsed");
10793 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
10794 }, 1000);
10795
10796 var warnShown = false, pollRetries = 0, activeWaitId = null;
10797
10798 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
10799
10800 function lcShowCancelled() {
10801 clearInterval(elapsedTimer);
10802 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
10803 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
10804 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
10805 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
10806 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
10807 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
10808 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
10809 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
10810 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10811 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10812 }
10813
10814 var lcCancelBtn = document.getElementById("lc-cancel-btn");
10815 if (lcCancelBtn) {
10816 lcCancelBtn.onclick = function() {
10817 if (!activeWaitId) { dismissAnalysisModal(); return; }
10818 lcCancelBtn.disabled = true;
10819 lcCancelBtn.textContent = "Cancelling…";
10820 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
10821 .then(function() { lcShowCancelled(); })
10822 .catch(function() { lcShowCancelled(); });
10823 };
10824 }
10825
10826 function lcShowError(msg) {
10827 clearInterval(elapsedTimer);
10828 lcSetPhase("Failed");
10829 var msgEl = document.getElementById("lc-err-msg");
10830 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
10831 var errEl = document.getElementById("lc-err");
10832 var actEl = document.getElementById("lc-actions");
10833 if (errEl) errEl.classList.remove("hidden");
10834 if (actEl) actEl.classList.remove("hidden");
10835 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10836 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10837 }
10838
10839 function lcPoll(waitId) {
10840 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
10841 .then(function(r) {
10842 if (!r.ok) throw new Error("HTTP " + r.status);
10843 return r.json();
10844 })
10845 .then(function(data) {
10846 pollRetries = 0;
10847 if (data.state === "complete") {
10848 clearInterval(elapsedTimer);
10849 lcSetPhase("Done");
10850 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
10851 } else if (data.state === "failed") {
10852 lcShowError(data.message);
10853 } else if (data.state === "cancelled") {
10854 lcShowCancelled();
10855 } else {
10856 var s = Math.floor((Date.now() - startTime) / 1000);
10857 if (s > 90 && !warnShown) {
10858 warnShown = true;
10859 var w = document.getElementById("lc-warn");
10860 if (w) w.classList.remove("hidden");
10861 }
10862 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
10863 setTimeout(function() { lcPoll(waitId); }, 1500);
10864 }
10865 })
10866 .catch(function() {
10867 pollRetries++;
10868 if (pollRetries >= 5) {
10869 lcShowError("Lost connection to server. Reload to check status.");
10870 } else {
10871 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
10872 }
10873 });
10874 }
10875
10876 var params = new URLSearchParams(formData);
10877 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
10878 .then(function(r) {
10879 var waitId = r.headers.get("x-wait-id");
10880 if (!waitId) { window.location.href = "/scan"; return; }
10881 activeWaitId = waitId;
10882 setTimeout(function() { lcPoll(waitId); }, 1500);
10883 })
10884 .catch(function(err) {
10885 lcShowError("Could not reach server: " + (err.message || err));
10886 });
10887 }
10888
10889 if (quickScanBtn) {
10890 quickScanBtn.addEventListener("click", function () {
10891 var pathVal = pathInput ? pathInput.value.trim() : "";
10892 if (!pathVal) {
10893 alert("Please enter or browse to a project path first.");
10894 return;
10895 }
10896 quickScanBtn.disabled = true;
10897 quickScanBtn.textContent = "Scanning...";
10898 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
10899 startAsyncAnalysis(new FormData(form));
10900 });
10901 }
10902
10903 var mixedPolicyInfo = {
10904 code_only: {
10905 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.",
10906 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'
10907 },
10908 code_and_comment: {
10909 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.",
10910 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'
10911 },
10912 comment_only: {
10913 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.",
10914 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'
10915 },
10916 separate_mixed_category: {
10917 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.",
10918 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'
10919 }
10920 };
10921
10922 var scanPresetInfo = {
10923 balanced: {
10924 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.",
10925 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
10926 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
10927 note: "Best when you want a stable local overview before making deeper adjustments.",
10928 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10929 },
10930 code_focused: {
10931 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
10932 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
10933 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
10934 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
10935 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10936 },
10937 comment_audit: {
10938 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
10939 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
10940 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
10941 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
10942 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10943 },
10944 deep_review: {
10945 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
10946 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
10947 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
10948 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
10949 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
10950 }
10951 };
10952
10953 var artifactPresetInfo = {
10954 review: {
10955 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.",
10956 chips: ["HTML", "PDF"],
10957 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
10958 },
10959 full: {
10960 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.",
10961 chips: ["HTML", "PDF", "JSON"],
10962 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
10963 },
10964 html_only: {
10965 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.",
10966 chips: ["HTML only", "Fast local review"],
10967 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
10968 },
10969 machine: {
10970 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
10971 chips: ["HTML", "JSON"],
10972 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
10973 }
10974 };
10975
10976 function applyTheme(theme) {
10977 if (theme === "dark") document.body.classList.add("dark-theme");
10978 else document.body.classList.remove("dark-theme");
10979 }
10980
10981 function loadSavedTheme() {
10982 var saved = null;
10983 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
10984 applyTheme(saved === "dark" ? "dark" : "light");
10985 }
10986
10987 function updateScrollProgress() {
10988 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
10989 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
10990 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
10991 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
10992 var step = Math.min(Math.max(currentStep, 1), 4);
10993 var base = stepBase[step];
10994 var end = stepEnd[step];
10995
10996 var scrollFrac = 0;
10997 var activePanel = document.querySelector(".wizard-step.active");
10998 if (activePanel) {
10999 var scrollTop = window.scrollY || window.pageYOffset || 0;
11000 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11001 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11002 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11003 var scrolled = scrollTop + viewH - panelTop;
11004 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11005 }
11006
11007 var percent = Math.round(base + (end - base) * scrollFrac);
11008 percent = Math.min(end, Math.max(base, percent));
11009 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11010 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11011 }
11012
11013 function updateWizardProgress() {
11014 updateScrollProgress();
11015 }
11016
11017 var stepDescriptions = [
11018 "Choose a project folder, apply scope filters, and preview which files will be counted.",
11019 "Configure how mixed code-plus-comment lines and docstrings are classified.",
11020 "Pick your output formats, scan preset, and where reports are saved.",
11021 "Review all settings and launch the analysis."
11022 ];
11023
11024 function updateStepNav(step) {
11025 var infoLabel = document.getElementById("step-nav-info-label");
11026 var infoDesc = document.getElementById("step-nav-info-desc");
11027 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11028 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
11029 }
11030
11031 function updateSidebarSummary() {
11032 var sumPath = document.getElementById("sum-path");
11033 var sumPreset = document.getElementById("sum-preset");
11034 var sumOutput = document.getElementById("sum-output");
11035 var sidebarSummary = document.getElementById("sidebar-summary");
11036 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11037 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
11038 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11039 if (sumPath) sumPath.textContent = pathVal || "—";
11040 if (sumPreset) sumPreset.textContent = presetVal || "—";
11041 if (sumOutput) sumOutput.textContent = outputVal || "—";
11042 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11043 }
11044
11045 function setStep(step, pushHistory) {
11046 currentStep = step;
11047 stepPanels.forEach(function (panel) {
11048 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11049 });
11050 stepButtons.forEach(function (button) {
11051 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11052 });
11053 var layoutEl = document.querySelector(".layout");
11054 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11055 updateWizardProgress();
11056 updateStepNav(step);
11057 stepButtons.forEach(function(btn) {
11058 var t = Number(btn.getAttribute("data-step-target"));
11059 btn.classList.toggle("done", t < step);
11060 });
11061 updateSidebarSummary();
11062
11063 if (pushHistory !== false) {
11064 try {
11065 history.pushState({ wizardStep: step }, "", "#step" + step);
11066 } catch (e) {}
11067 }
11068
11069 window.scrollTo({ top: 0, behavior: "instant" });
11070 }
11071
11072 window.addEventListener("popstate", function (e) {
11073 if (e.state && e.state.wizardStep) {
11074 setStep(e.state.wizardStep, false);
11075 } else {
11076 var hashMatch = location.hash.match(/^#step([1-4])$/);
11077 if (hashMatch) setStep(Number(hashMatch[1]), false);
11078 }
11079 });
11080
11081 function inferTitleFromPath(value) {
11082 if (!value) return "project";
11083 var cleaned = value.replace(/[\/\\]+$/, "");
11084 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11085 return parts.length ? parts[parts.length - 1] : value;
11086 }
11087
11088 function updateReportTitleFromPath() {
11089 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11090 if (!reportTitleTouched) {
11091 reportTitleInput.value = inferred;
11092 }
11093 var title = reportTitleInput.value || inferred;
11094 if (liveReportTitle) liveReportTitle.textContent = title;
11095 if (reportTitlePreview) reportTitlePreview.textContent = title;
11096 document.title = "OxideSLOC | " + title;
11097
11098 var projectPath = (pathInput.value || "").trim();
11099 if (navProjectPill && navProjectTitle) {
11100 if (projectPath.length > 0) {
11101 navProjectTitle.textContent = inferred;
11102 navProjectPill.classList.add("visible");
11103 } else {
11104 navProjectTitle.textContent = "";
11105 navProjectPill.classList.remove("visible");
11106 }
11107 }
11108 }
11109
11110 function updateMixedPolicyUI() {
11111 var key = mixedLinePolicy.value || "code_only";
11112 var info = mixedPolicyInfo[key];
11113 document.getElementById("mixed-policy-description").textContent = info.description;
11114 document.getElementById("mixed-policy-example").textContent = info.example;
11115 }
11116
11117 function updatePythonDocstringUI() {
11118 var checked = !!pythonDocstrings.checked;
11119 document.getElementById("python-docstring-example").textContent = checked
11120 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
11121 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
11122 document.getElementById("python-docstring-live-help").textContent = checked
11123 ? "Enabled: docstrings contribute to comment-style totals."
11124 : "Disabled: docstrings are not counted as comment content.";
11125 }
11126
11127 function renderPresetChips(targetId, chips) {
11128 var target = document.getElementById(targetId);
11129 if (!target) return;
11130 target.innerHTML = (chips || []).map(function (chip) {
11131 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11132 }).join('');
11133 }
11134
11135 function updatePresetDescriptions() {
11136 var scanInfo = scanPresetInfo[scanPreset.value];
11137 var artifactInfo = artifactPresetInfo[artifactPreset.value];
11138 document.getElementById("scan-preset-description").textContent = scanInfo.description;
11139 document.getElementById("scan-preset-example").textContent = scanInfo.example;
11140 document.getElementById("scan-preset-note").textContent = scanInfo.note;
11141 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11142 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11143 renderPresetChips("scan-preset-summary", scanInfo.chips);
11144 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11145 }
11146
11147 function applyScanPreset() {
11148 var info = scanPresetInfo[scanPreset.value];
11149 if (!info || !info.apply) return;
11150 mixedLinePolicy.value = info.apply.mixed;
11151 pythonDocstrings.checked = !!info.apply.docstrings;
11152 document.getElementById("generated_file_detection").value = info.apply.generated;
11153 document.getElementById("minified_file_detection").value = info.apply.minified;
11154 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11155 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11156 document.getElementById("binary_file_behavior").value = info.apply.binary;
11157 updateMixedPolicyUI();
11158 updatePythonDocstringUI();
11159 }
11160
11161 function applyArtifactPreset() {
11162 var enabled = { html: false, pdf: false };
11163 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11164 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11165 if (artifactPreset.value === "html_only") { enabled.html = true; }
11166 if (artifactPreset.value === "machine") { enabled.html = true; }
11167
11168 artifactCards.forEach(function (card) {
11169 var artifact = card.getAttribute("data-artifact");
11170 if (artifact === "json") return;
11171 var checked = !!enabled[artifact];
11172 var checkbox = card.querySelector(".artifact-checkbox");
11173 checkbox.checked = checked;
11174 card.classList.toggle("selected", checked);
11175 });
11176 }
11177
11178 function toggleArtifactCard(card) {
11179 var checkbox = card.querySelector(".artifact-checkbox");
11180 checkbox.checked = !checkbox.checked;
11181 card.classList.toggle("selected", checkbox.checked);
11182 }
11183
11184 function updateReview() {
11185 var scanSummary = document.getElementById("review-scan-summary");
11186 var countSummary = document.getElementById("review-count-summary");
11187 var artifactSummary = document.getElementById("review-artifact-summary");
11188 var outputSummary = document.getElementById("review-output-summary");
11189 var previewSummary = document.getElementById("review-preview-summary");
11190 var readinessSummary = document.getElementById("review-readiness-summary");
11191 var includeText = document.getElementById("include_globs").value.trim();
11192 var excludeText = document.getElementById("exclude_globs").value.trim();
11193 var sidePathPreview = document.getElementById("side-path-preview");
11194 var sideOutputPreview = document.getElementById("side-output-preview");
11195 var sideTitlePreview = document.getElementById("side-title-preview");
11196
11197 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11198 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11199 if (sideTitlePreview) {
11200 var rt = document.getElementById("report_title");
11201 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11202 }
11203
11204 scanSummary.innerHTML = ""
11205 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11206 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11207 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11208
11209 countSummary.innerHTML = ""
11210 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11211 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11212 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11213 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11214 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11215 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11216 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11217 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11218
11219 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11220 artifactSummary.innerHTML = ""
11221 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11222 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11223
11224 outputSummary.innerHTML = ""
11225 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11226 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11227
11228 if (previewSummary) {
11229 if (GIT_MODE) {
11230 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>';
11231 } else {
11232 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11233 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11234 var statMap = {};
11235 statButtons.forEach(function (button) {
11236 var valueNode = button.querySelector('.scope-stat-value');
11237 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11238 });
11239 previewSummary.innerHTML = ''
11240 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11241 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11242 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11243 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11244 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11245 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11246
11247 if (readinessSummary) {
11248 var selectedArtifactsCount = selectedArtifacts.length;
11249 readinessSummary.innerHTML = ''
11250 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11251 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11252 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11253 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11254 }
11255 } // end else (non-GIT_MODE)
11256 }
11257 }
11258
11259 function escapeHtml(value) {
11260 return String(value)
11261 .replace(/&/g, "&")
11262 .replace(/</g, "<")
11263 .replace(/>/g, ">")
11264 .replace(/"/g, """)
11265 .replace(/'/g, "'");
11266 }
11267
11268 function isPythonVisible() {
11269 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11270 }
11271
11272 function syncPythonVisibility() {
11273 var html = previewPanel.textContent || "";
11274 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11275 pythonWraps.forEach(function (node) {
11276 node.classList.toggle("hidden", !hasPython);
11277 });
11278 }
11279
11280 function attachPreviewInteractions() {
11281 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11282 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11283 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11284 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11285 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11286 var searchInput = previewPanel.querySelector("#explorer-search");
11287 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11288 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11289 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11290 var activeFilter = "all";
11291 var activeLanguage = "";
11292 var searchTerm = "";
11293 var currentSortKey = null;
11294 var currentSortOrder = "asc";
11295 var childRows = {};
11296
11297 rows.forEach(function (row) {
11298 var parentId = row.getAttribute("data-parent-id") || "";
11299 var rowId = row.getAttribute("data-row-id") || "";
11300 if (!childRows[parentId]) childRows[parentId] = [];
11301 childRows[parentId].push(rowId);
11302 });
11303
11304 function rowById(id) {
11305 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11306 }
11307
11308 function hasCollapsedAncestor(row) {
11309 var parentId = row.getAttribute("data-parent-id");
11310 while (parentId) {
11311 var parent = rowById(parentId);
11312 if (!parent) break;
11313 if (parent.getAttribute("data-expanded") === "false") return true;
11314 parentId = parent.getAttribute("data-parent-id");
11315 }
11316 return false;
11317 }
11318
11319 function updateToggleGlyph(row) {
11320 var toggle = row.querySelector(".tree-toggle");
11321 if (!toggle) return;
11322 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11323 }
11324
11325 function rowSortValue(row, key) {
11326 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11327 }
11328
11329 function updateSortButtons() {
11330 sortButtons.forEach(function (button) {
11331 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11332 var indicator = button.querySelector(".tree-sort-indicator");
11333 button.classList.toggle("active", isActive);
11334 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11335 if (indicator) {
11336 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11337 }
11338 });
11339 }
11340
11341 function sortSiblingRows() {
11342 if (!treeContainer) {
11343 updateSortButtons();
11344 return;
11345 }
11346
11347 var rowMap = {};
11348 var childrenMap = {};
11349 rows.forEach(function (row) {
11350 var rowId = row.getAttribute("data-row-id");
11351 var parentId = row.getAttribute("data-parent-id") || "";
11352 rowMap[rowId] = row;
11353 if (!childrenMap[parentId]) childrenMap[parentId] = [];
11354 childrenMap[parentId].push(rowId);
11355 });
11356
11357 Object.keys(childrenMap).forEach(function (parentId) {
11358 if (!parentId) return;
11359 childrenMap[parentId].sort(function (a, b) {
11360 var rowA = rowMap[a];
11361 var rowB = rowMap[b];
11362 if (!currentSortKey) {
11363 return Number(a) - Number(b);
11364 }
11365 var valueA = rowSortValue(rowA, currentSortKey);
11366 var valueB = rowSortValue(rowB, currentSortKey);
11367 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11368 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11369 var fallbackA = rowSortValue(rowA, "name");
11370 var fallbackB = rowSortValue(rowB, "name");
11371 if (fallbackA < fallbackB) return -1;
11372 if (fallbackA > fallbackB) return 1;
11373 return Number(a) - Number(b);
11374 });
11375 });
11376
11377 var orderedIds = [];
11378 function pushChildren(parentId) {
11379 (childrenMap[parentId] || []).forEach(function (childId) {
11380 orderedIds.push(childId);
11381 pushChildren(childId);
11382 });
11383 }
11384
11385 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11386 orderedIds.push(topId);
11387 pushChildren(topId);
11388 });
11389
11390 orderedIds.forEach(function (id) {
11391 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11392 });
11393 updateSortButtons();
11394 }
11395
11396 function updateLanguageButtons() {
11397 languageButtons.forEach(function (button) {
11398 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11399 var isActive = languageValue === activeLanguage;
11400 button.classList.toggle("active", isActive);
11401 });
11402 }
11403
11404 function rowSelfMatches(row) {
11405 var kind = row.getAttribute("data-kind");
11406 var status = row.getAttribute("data-status");
11407 var language = (row.getAttribute("data-language") || "").toLowerCase();
11408 var name = row.getAttribute("data-name-lower") || "";
11409 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11410 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11411 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11412 var passesLanguage = !activeLanguage || language === activeLanguage;
11413 return passesFilter && passesSearch && passesLanguage;
11414 }
11415
11416 function hasMatchingDescendant(rowId) {
11417 return (childRows[rowId] || []).some(function (childId) {
11418 var childRow = rowById(childId);
11419 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11420 });
11421 }
11422
11423 function rowMatches(row) {
11424 if (rowSelfMatches(row)) return true;
11425 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11426 }
11427
11428 function resetViewState() {
11429 activeFilter = "all";
11430 activeLanguage = "";
11431 searchTerm = "";
11432 currentSortKey = null;
11433 currentSortOrder = "asc";
11434 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11435 if (searchInput) searchInput.value = "";
11436 if (filterSelect) filterSelect.value = "all";
11437 updateLanguageButtons();
11438 }
11439
11440 function applyVisibility() {
11441 rows.forEach(function (row) {
11442 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11443 row.classList.toggle("hidden-by-filter", !visible);
11444 row.style.display = visible ? "grid" : "none";
11445 });
11446 buttons.forEach(function (button) {
11447 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11448 });
11449 if (filterSelect) filterSelect.value = activeFilter;
11450 }
11451
11452 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11453 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11454 var originalStats = {};
11455 buttons.forEach(function (btn) {
11456 var f = btn.getAttribute('data-filter');
11457 var v = btn.querySelector('.scope-stat-value');
11458 if (f && v) originalStats[f] = v.textContent;
11459 });
11460
11461 function applySubmoduleStats(statsJson) {
11462 try {
11463 var s = JSON.parse(statsJson);
11464 buttons.forEach(function (btn) {
11465 var f = btn.getAttribute('data-filter');
11466 var v = btn.querySelector('.scope-stat-value');
11467 if (!v) return;
11468 if (f === 'dir') v.textContent = s.dirs;
11469 else if (f === 'file') v.textContent = s.files;
11470 else if (f === 'supported') v.textContent = s.supported;
11471 else if (f === 'skipped') v.textContent = s.skipped;
11472 else if (f === 'unsupported') v.textContent = s.unsupported;
11473 });
11474 } catch (e) {}
11475 }
11476
11477 function restoreBaseRepoStats() {
11478 buttons.forEach(function (btn) {
11479 var f = btn.getAttribute('data-filter');
11480 var v = btn.querySelector('.scope-stat-value');
11481 if (v && originalStats[f]) v.textContent = originalStats[f];
11482 });
11483 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11484 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11485 }
11486
11487 submoduleChips.forEach(function (chip) {
11488 chip.addEventListener('click', function () {
11489 var statsJson = chip.getAttribute('data-sub-stats');
11490 if (!statsJson) return;
11491 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11492 chip.classList.add('active');
11493 applySubmoduleStats(statsJson);
11494 if (baseRepoBtn) baseRepoBtn.style.display = '';
11495 });
11496 });
11497
11498 if (baseRepoBtn) {
11499 baseRepoBtn.addEventListener('click', function () {
11500 restoreBaseRepoStats();
11501 resetViewState();
11502 sortSiblingRows();
11503 applyVisibility();
11504 });
11505 }
11506
11507 buttons.forEach(function (button) {
11508 button.addEventListener("click", function () {
11509 var filterValue = button.getAttribute("data-filter") || "all";
11510 if (filterValue === "reset-view") {
11511 restoreBaseRepoStats();
11512 resetViewState();
11513 sortSiblingRows();
11514 applyVisibility();
11515 return;
11516 }
11517 activeFilter = filterValue;
11518 applyVisibility();
11519 });
11520 });
11521
11522 rows.forEach(function (row) {
11523 updateToggleGlyph(row);
11524 var toggle = row.querySelector(".tree-toggle");
11525 if (toggle) {
11526 toggle.addEventListener("click", function () {
11527 var expanded = row.getAttribute("data-expanded") !== "false";
11528 row.setAttribute("data-expanded", expanded ? "false" : "true");
11529 updateToggleGlyph(row);
11530 applyVisibility();
11531 });
11532 }
11533 });
11534
11535 actionButtons.forEach(function (button) {
11536 button.addEventListener("click", function () {
11537 var action = button.getAttribute("data-explorer-action");
11538 if (action === "expand-all") {
11539 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11540 } else if (action === "collapse-all") {
11541 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11542 } else if (action === "clear-filters") {
11543 resetViewState();
11544 }
11545 sortSiblingRows();
11546 applyVisibility();
11547 });
11548 });
11549
11550 if (filterSelect) {
11551 filterSelect.addEventListener("change", function () {
11552 activeFilter = filterSelect.value || "all";
11553 applyVisibility();
11554 });
11555 }
11556
11557 languageButtons.forEach(function (button) {
11558 button.addEventListener("click", function () {
11559 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11560 updateLanguageButtons();
11561 applyVisibility();
11562 });
11563 });
11564
11565 sortButtons.forEach(function (button) {
11566 button.addEventListener("click", function () {
11567 var sortKey = button.getAttribute("data-sort-key");
11568 if (currentSortKey === sortKey) {
11569 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11570 } else {
11571 currentSortKey = sortKey;
11572 currentSortOrder = "asc";
11573 }
11574 sortSiblingRows();
11575 applyVisibility();
11576 });
11577 });
11578
11579 if (searchInput) {
11580 searchInput.addEventListener("input", function () {
11581 searchTerm = searchInput.value.trim().toLowerCase();
11582 applyVisibility();
11583 });
11584 }
11585
11586 updateLanguageButtons();
11587 sortSiblingRows();
11588 applyVisibility();
11589 }
11590
11591 function loadPreview() {
11592 if (!previewPanel || !pathInput) return;
11593 if (GIT_MODE) {
11594 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>';
11595 return;
11596 }
11597 var path = pathInput.value.trim();
11598 var zeroWarn = document.getElementById('zero-files-warning');
11599 if (!path) {
11600 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11601 if (zeroWarn) zeroWarn.style.display = 'none';
11602 return;
11603 }
11604 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11605 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11606 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11607 var previewUrl = "/preview?path=" + encodeURIComponent(path)
11608 + "&include_globs=" + encodeURIComponent(includeValue)
11609 + "&exclude_globs=" + encodeURIComponent(excludeValue);
11610 fetch(previewUrl)
11611 .then(function (response) { return response.text(); })
11612 .then(function (html) {
11613 previewPanel.innerHTML = html;
11614 attachPreviewInteractions();
11615 syncPythonVisibility();
11616 updateReview();
11617 setTimeout(collapseLanguagePills, 50);
11618 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11619 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11620 var sizeText = document.getElementById('project-size-text');
11621 var sizeBtn = document.getElementById('project-size-btn');
11622 if (sizeText && projectSize) {
11623 sizeText.textContent = 'Project size: ' + projectSize;
11624 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11625 } else if (sizeText) {
11626 sizeText.textContent = 'Project size: —';
11627 }
11628 if (zeroWarn) {
11629 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11630 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11631 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11632 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11633 if (supportedCount === 0 && fileCount > 0) {
11634 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).';
11635 zeroWarn.style.display = '';
11636 } else {
11637 zeroWarn.style.display = 'none';
11638 }
11639 }
11640 })
11641 .catch(function (err) {
11642 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11643 });
11644 }
11645
11646 function pickDirectory(targetInput, kind) {
11647 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11648 if (browseButton) browseButton.disabled = true;
11649
11650 if (previewPanel && targetInput === pathInput) {
11651 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11652 }
11653
11654 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
11655 .then(function (response) { return response.json(); })
11656 .then(function (data) {
11657 if (data && data.selected_path) {
11658 targetInput.value = data.selected_path;
11659
11660 if (targetInput === pathInput) {
11661 updateReportTitleFromPath();
11662 autoSetOutputDir(data.selected_path);
11663 fetchProjectHistory(data.selected_path);
11664 loadPreview();
11665 suggestCoverageFile(data.selected_path);
11666 }
11667
11668 updateReview();
11669 } else if (targetInput === pathInput) {
11670 // Cancelled — keep existing value and refresh preview with current path
11671 loadPreview();
11672 }
11673 })
11674 .catch(function () {
11675 window.alert("Directory picker request failed.");
11676 if (previewPanel && targetInput === pathInput) {
11677 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11678 }
11679 })
11680 .finally(function () {
11681 if (browseButton) browseButton.disabled = false;
11682 });
11683 }
11684
11685 if (themeToggle) {
11686 themeToggle.addEventListener("click", function () {
11687 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11688 applyTheme(nextTheme);
11689 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11690 });
11691 }
11692
11693 stepButtons.forEach(function (button) {
11694 button.addEventListener("click", function () {
11695 setStep(Number(button.getAttribute("data-step-target")));
11696 });
11697 });
11698
11699 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11700 button.addEventListener("click", function () {
11701 setStep(Number(button.getAttribute("data-step-target")) || 1);
11702 });
11703 });
11704
11705 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11706 button.addEventListener("click", function () {
11707 updateReview();
11708 setStep(Number(button.getAttribute("data-next")));
11709 });
11710 });
11711
11712 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11713 button.addEventListener("click", function () {
11714 setStep(Number(button.getAttribute("data-prev")));
11715 });
11716 });
11717
11718 document.addEventListener("keydown", function (e) {
11719 var tag = (document.activeElement || {}).tagName || "";
11720 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11721 if (e.altKey || e.ctrlKey || e.metaKey) return;
11722 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11723 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11724 });
11725
11726 if (useSamplePath) {
11727 useSamplePath.addEventListener("click", function () {
11728 pathInput.value = "tests/fixtures/basic";
11729 updateReportTitleFromPath();
11730 autoSetOutputDir("tests/fixtures/basic");
11731 loadPreview();
11732 suggestCoverageFile("tests/fixtures/basic");
11733 });
11734 }
11735
11736 if (useDefaultOutput) {
11737 useDefaultOutput.addEventListener("click", function () {
11738 delete outputDirInput.dataset.userEdited;
11739 autoSetOutputDir(pathInput ? pathInput.value : "");
11740 updateReview();
11741 });
11742 }
11743
11744 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11745 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11746 if (browseCoverage) {
11747 browseCoverage.addEventListener("click", function () {
11748 browseCoverage.disabled = true;
11749 var currentVal = coverageInput ? coverageInput.value : "";
11750 fetch("/pick-directory?kind=coverage¤t=" + encodeURIComponent(currentVal))
11751 .then(function (r) { return r.json(); })
11752 .then(function (d) {
11753 if (d && d.selected_path && coverageInput) {
11754 coverageInput.value = d.selected_path;
11755 setCovStatus("idle");
11756 }
11757 })
11758 .catch(function () {})
11759 .finally(function () { browseCoverage.disabled = false; });
11760 });
11761 }
11762
11763 function setCovStatus(state, opts) {
11764 if (!covScanStatus) return;
11765 opts = opts || {};
11766 covScanStatus.className = "cov-scan-status cov-scan-" + state;
11767 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11768 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>';
11769 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>';
11770 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>';
11771 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>';
11772 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11773 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11774 if (state === "scanning") {
11775 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11776 } else if (state === "found") {
11777 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11778 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
11779 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
11780 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
11781 } else if (state === "hint") {
11782 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11783 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
11784 html += '<div class="cov-scan-sub">Generate one with:</div>';
11785 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
11786 } else if (state === "none") {
11787 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
11788 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
11789 }
11790 html += '</div></div>';
11791 covScanStatus.innerHTML = html;
11792 if (state === "found") {
11793 var useBtn = covScanStatus.querySelector(".cov-scan-use");
11794 if (useBtn) useBtn.addEventListener("click", function () {
11795 if (coverageInput) coverageInput.value = "";
11796 covAutoFilled = false;
11797 setCovStatus("idle");
11798 });
11799 }
11800 }
11801
11802 function suggestCoverageFile(projectPath) {
11803 if (!coverageInput || !covScanStatus) return;
11804 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11805 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
11806 clearTimeout(coverageSuggestTimer);
11807 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
11808 setCovStatus("scanning");
11809 coverageSuggestTimer = setTimeout(function () {
11810 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
11811 .then(function (r) { return r.json(); })
11812 .then(function (d) {
11813 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11814 if (!d) { setCovStatus("none"); return; }
11815 if (d.found) {
11816 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
11817 setCovStatus("found", { found: d.found, tool: d.tool });
11818 } else if (d.tool && d.hint) {
11819 setCovStatus("hint", { tool: d.tool, hint: d.hint });
11820 } else {
11821 setCovStatus("none");
11822 }
11823 })
11824 .catch(function () { setCovStatus("idle"); });
11825 }, 600);
11826 }
11827
11828 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
11829
11830 if (coverageInput) coverageInput.addEventListener("input", function () {
11831 covAutoFilled = false;
11832 if (!this.value.trim()) setCovStatus("idle");
11833 });
11834
11835 // ── Language pill overflow: collapse to "+N more" chip ─────────────
11836 function collapseLanguagePills() {
11837 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
11838 rows.forEach(function(row) {
11839 // Remove any previous overflow chip
11840 var prev = row.querySelector('.lang-overflow-chip');
11841 if (prev) prev.remove();
11842 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
11843 pills.forEach(function(p) { p.style.display = ''; });
11844 if (!pills.length) return;
11845
11846 // Measure after restoring all pills
11847 var containerRight = row.getBoundingClientRect().right;
11848 var hidden = [];
11849 for (var i = pills.length - 1; i >= 1; i--) {
11850 var rect = pills[i].getBoundingClientRect();
11851 if (rect.right > containerRight + 2) {
11852 hidden.unshift(pills[i]);
11853 pills[i].style.display = 'none';
11854 } else {
11855 break;
11856 }
11857 }
11858
11859 if (hidden.length) {
11860 var chip = document.createElement('button');
11861 chip.type = 'button';
11862 chip.className = 'language-pill lang-overflow-chip';
11863 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
11864 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
11865 row.appendChild(chip);
11866 }
11867 });
11868 }
11869
11870 // Run after preview loads (preview panel populates language pills)
11871 var _origLoadPreviewCb = window.__previewLoaded;
11872 document.addEventListener('previewLoaded', collapseLanguagePills);
11873 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
11874 setTimeout(collapseLanguagePills, 400);
11875
11876 // ── Project history & output dir auto-set ──────────────────────────
11877 var wsOutputRoot = document.getElementById("ws-output-root");
11878 var wsScanCount = document.getElementById("ws-scan-count");
11879 var wsLastScan = document.getElementById("ws-last-scan");
11880 var historyBadge = document.getElementById("path-history-badge");
11881 var historyTimer = null;
11882
11883 var wsOutputLink = document.getElementById("ws-output-link");
11884 function syncStripOutputRoot() {
11885 var val = outputDirInput ? outputDirInput.value : "";
11886 var display = val || "project/sloc";
11887 if (wsOutputRoot) wsOutputRoot.textContent = display;
11888 if (wsOutputLink) wsOutputLink.dataset.folder = val;
11889 }
11890
11891 function autoSetOutputDir(projectPath) {
11892 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
11893 if (GIT_MODE && GIT_OUTPUT_DIR) {
11894 outputDirInput.value = GIT_OUTPUT_DIR;
11895 syncStripOutputRoot();
11896 updateReview();
11897 return;
11898 }
11899 if (!projectPath || !projectPath.trim()) return;
11900 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
11901 outputDirInput.value = cleaned + "/sloc";
11902 syncStripOutputRoot();
11903 updateReview();
11904 }
11905
11906 var wsBranch = document.getElementById("ws-branch");
11907
11908 function fetchProjectHistory(projectPath) {
11909 if (!projectPath || !projectPath.trim()) {
11910 if (wsScanCount) wsScanCount.textContent = "—";
11911 if (wsLastScan) wsLastScan.textContent = "—";
11912 if (wsBranch) wsBranch.textContent = "—";
11913 if (historyBadge) historyBadge.style.display = "none";
11914 return;
11915 }
11916 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
11917 .then(function (r) { return r.ok ? r.json() : null; })
11918 .then(function (data) {
11919 if (!data) return;
11920 var countStr = data.scan_count > 0
11921 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
11922 : "never";
11923 var tsStr = data.last_scan_timestamp
11924 ? data.last_scan_timestamp.replace(" UTC","")
11925 : "—";
11926 if (wsScanCount) wsScanCount.textContent = countStr;
11927 if (wsLastScan) wsLastScan.textContent = tsStr;
11928 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
11929 if (data.scan_count > 0) {
11930 if (historyBadge) {
11931 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
11932 historyBadge.textContent = data.scan_count + " previous scan" +
11933 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
11934 "Last: " + (data.last_scan_timestamp || "—") +
11935 " — " + (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.";
11936 historyBadge.className = "path-history-badge found";
11937 historyBadge.style.display = "";
11938 }
11939 } else {
11940 if (historyBadge) historyBadge.style.display = "none";
11941 }
11942 })
11943 .catch(function () {});
11944 }
11945
11946 function onPathChange() {
11947 var val = pathInput ? pathInput.value : "";
11948 updateReportTitleFromPath();
11949 autoSetOutputDir(val);
11950 updateSidebarSummary();
11951 clearTimeout(historyTimer);
11952 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
11953 if (previewTimer) clearTimeout(previewTimer);
11954 previewTimer = setTimeout(loadPreview, 280);
11955 suggestCoverageFile(val);
11956 }
11957
11958 if (pathInput) {
11959 pathInput.addEventListener("input", onPathChange);
11960 }
11961
11962 if (outputDirInput) {
11963 outputDirInput.addEventListener("input", function () {
11964 outputDirInput.dataset.userEdited = "1";
11965 syncStripOutputRoot();
11966 updateReview();
11967 });
11968 }
11969
11970 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
11971 if (!node) return;
11972 node.addEventListener("input", function () {
11973 updateReview();
11974 if (previewTimer) clearTimeout(previewTimer);
11975 previewTimer = setTimeout(loadPreview, 280);
11976 });
11977 });
11978
11979 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
11980 var node = document.getElementById(id);
11981 if (node) node.addEventListener("change", updateReview);
11982 });
11983
11984 if (reportTitleInput) {
11985 reportTitleInput.addEventListener("input", function () {
11986 reportTitleTouched = reportTitleInput.value.trim().length > 0;
11987 updateReportTitleFromPath();
11988 updateReview();
11989 });
11990 }
11991
11992 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
11993 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
11994 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
11995 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
11996
11997 artifactCards.forEach(function (card) {
11998 card.addEventListener("click", function () {
11999 if (card.classList.contains("artifact-locked")) return;
12000 toggleArtifactCard(card);
12001 updateReview();
12002 });
12003 });
12004
12005 if (coverageInput) {
12006 coverageInput.addEventListener("input", function () {
12007 if (coverageInput.value.trim()) setCovStatus("idle");
12008 });
12009 }
12010
12011 if (form && loading && submitButton) {
12012 form.addEventListener("submit", function (e) {
12013 e.preventDefault();
12014 submitButton.disabled = true;
12015 submitButton.textContent = "Scanning...";
12016 startAsyncAnalysis(new FormData(form));
12017 });
12018 }
12019
12020 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12021 btn.addEventListener('click', function () {
12022 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12023 if (!folder) return;
12024 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12025 });
12026 });
12027
12028 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12029 if (wsOutputLink) {
12030 wsOutputLink.addEventListener('click', function () {
12031 var folder = wsOutputLink.dataset.folder || '';
12032 if (!folder) return;
12033 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12034 });
12035 }
12036
12037 loadSavedTheme();
12038 updateMixedPolicyUI();
12039 updatePythonDocstringUI();
12040 applyScanPreset();
12041 updatePresetDescriptions();
12042 applyArtifactPreset();
12043 updateReview();
12044 updateScrollProgress(); // initialise bar to 0% (step 1)
12045 window.addEventListener("scroll", updateScrollProgress, { passive: true });
12046 onPathChange(); // seed output dir, history badge, and preview from initial path
12047 loadPreview();
12048 updateStepNav(1);
12049
12050 // Restore step from URL hash on initial load (e.g., back-forward cache)
12051 (function() {
12052 var hashMatch = location.hash.match(/^#step([1-4])$/);
12053 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12054 })();
12055
12056 (function randomizeWatermarks() {
12057 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12058 if (!wms.length) return;
12059 var placed = [];
12060 function tooClose(top, left) {
12061 for (var i = 0; i < placed.length; i++) {
12062 var dt = Math.abs(placed[i][0] - top);
12063 var dl = Math.abs(placed[i][1] - left);
12064 if (dt < 16 && dl < 12) return true;
12065 }
12066 return false;
12067 }
12068 function pick(leftBand) {
12069 for (var attempt = 0; attempt < 50; attempt++) {
12070 var top = Math.random() * 88 + 2;
12071 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12072 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12073 }
12074 var top = Math.random() * 88 + 2;
12075 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12076 placed.push([top, left]);
12077 return [top, left];
12078 }
12079 var half = Math.floor(wms.length / 2);
12080 wms.forEach(function (img, i) {
12081 var pos = pick(i < half);
12082 var size = Math.floor(Math.random() * 80 + 110);
12083 var rot = (Math.random() * 360).toFixed(1);
12084 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12085 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;
12086 });
12087 })();
12088
12089 (function spawnCodeParticles() {
12090 var container = document.getElementById('code-particles');
12091 if (!container) return;
12092 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'];
12093 for (var i = 0; i < 38; i++) {
12094 (function(idx) {
12095 var el = document.createElement('span');
12096 el.className = 'code-particle';
12097 el.textContent = snippets[idx % snippets.length];
12098 var left = Math.random() * 94 + 2;
12099 var top = Math.random() * 88 + 6;
12100 var dur = (Math.random() * 10 + 9).toFixed(1);
12101 var delay = (Math.random() * 18).toFixed(1);
12102 var rot = (Math.random() * 26 - 13).toFixed(1);
12103 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12104 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';
12105 container.appendChild(el);
12106 })(i);
12107 }
12108 })();
12109 })();
12110 </script>
12111 <script nonce="{{ csp_nonce }}">
12112 (function () {
12113 var raw = {{ prefill_json|safe }};
12114 if (!raw || typeof raw !== 'object' || !raw.path) return;
12115 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12116 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12117 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12118 setVal('path-input', raw.path || '');
12119 setVal('include-globs', raw.include_globs || '');
12120 setVal('exclude-globs', raw.exclude_globs || '');
12121 setVal('output-dir', raw.output_dir || '');
12122 setVal('report-title', raw.report_title || '');
12123 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12124 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12125 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12126 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12127 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12128 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12129 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12130 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12131 setChecked('generate-html', raw.generate_html !== false);
12132 setChecked('generate-pdf', !!raw.generate_pdf);
12133 // Trigger dynamic UI updates after pre-fill.
12134 setTimeout(function () {
12135 var pathEl = document.getElementById('path-input');
12136 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12137 var policyEl = document.getElementById('mixed-line-policy');
12138 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12139 }, 80);
12140 })();
12141 </script>
12142 <script nonce="{{ csp_nonce }}">
12143 (function(){
12144 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'}];
12145 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);});}
12146 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12147 function init(){
12148 var btn=document.getElementById('settings-btn');if(!btn)return;
12149 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12150 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>';
12151 document.body.appendChild(m);
12152 var g=document.getElementById('scheme-grid');
12153 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);});
12154 var cl=document.getElementById('settings-close');
12155 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);
12156 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');});
12157 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12158 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12159 }
12160 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12161 }());
12162 </script>
12163 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12164 <div class="wb-ftip-arrow"></div>
12165 <span id="wb-ftip-text"></span>
12166 </div>
12167 <script nonce="{{ csp_nonce }}">(function(){
12168 var tip=document.getElementById('wb-ftip');
12169 var txt=document.getElementById('wb-ftip-text');
12170 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12171 if(!tip||!txt)return;
12172 function pos(el){
12173 var r=el.getBoundingClientRect();
12174 tip.style.display='block';
12175 var tw=tip.offsetWidth;
12176 var lx=r.left+r.width/2-tw/2;
12177 if(lx<8)lx=8;
12178 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12179 tip.style.left=lx+'px';
12180 tip.style.top=(r.bottom+8)+'px';
12181 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';}
12182 }
12183 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12184 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12185 el.addEventListener('mouseleave',function(){tip.style.display='none';});
12186 });
12187 })();
12188 (function(){
12189 function fixArtifactHintSpacing(){
12190 var grid=document.querySelector('.artifact-grid');
12191 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12192 }
12193 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12194 }());
12195 </script>
12196 <footer class="site-footer">
12197 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12198 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12199 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12200 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12201 · <a href="/api-docs" rel="noopener">REST API</a>
12202 </footer>
12203</body>
12204</html>
12205"##,
12206 ext = "html"
12207)]
12208struct IndexTemplate {
12209 version: &'static str,
12210 prefill_json: String,
12211 csp_nonce: String,
12212 git_repo: String,
12213 git_ref: String,
12214 git_label_json: String,
12215 git_output_dir_json: String,
12216}
12217
12218#[derive(Template)]
12221#[template(
12222 source = r##"
12223<!doctype html>
12224<html lang="en">
12225<head>
12226 <meta charset="utf-8">
12227 <meta name="viewport" content="width=device-width, initial-scale=1">
12228 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12229 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12230 <style nonce="{{ csp_nonce }}">
12231 :root {
12232 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12233 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12234 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12235 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12236 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12237 }
12238 body.dark-theme {
12239 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12240 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12241 }
12242 *{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);}
12243 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12244 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12245 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12246 .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;}
12247 @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));}}
12248 .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);}
12249 .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12250 .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));}
12251 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12252 .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;}
12253 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12254 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12255 @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; } }
12256 .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;}
12257 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12258 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12259 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12260 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12261 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12262 .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;}
12263 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12264 .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);}
12265 .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;}
12266 .settings-close:hover{color:var(--text);background:var(--surface-2);}
12267 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12268 .settings-modal-body{padding:14px 16px 16px;}
12269 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12270 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12271 .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;}
12272 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12273 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12274 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12275 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12276 .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;}
12277 .tz-select:focus{border-color:var(--oxide);}
12278 .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;}
12279 .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;}
12280 .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12281 .hero{text-align:center;margin:0 auto 18px;}
12282 .hero-logo-wrap{display:inline-block;cursor:default;}
12283 .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;}
12284 .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;}
12285 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12286 .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;}
12287 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%);}
12288 .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;
12289 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12290 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12291 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;}
12292 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12293 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12294 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;}
12295 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
12296 .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;}
12297 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12298 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12299 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12300 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12301 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12302 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12303 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12304 .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;}
12305 .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;}
12306 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12307 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12308 .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);}
12309 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12310 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12311 .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);}
12312 .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);}
12313 .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);}
12314 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12315 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12316 .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;}
12317 body.dark-theme .action-card-cta{color:var(--oxide);}
12318 .action-card.view .action-card-cta{color:var(--accent-2);}
12319 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12320 .action-card.compare .action-card-cta{color:#7c3aed;}
12321 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12322 .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);}
12323 .action-card.git-tools .action-card-cta{color:#15803d;}
12324 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12325 .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);}
12326 .action-card.trend .action-card-cta{color:#0e7490;}
12327 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12328 .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);}
12329 .action-card.automation .action-card-cta{color:#b45309;}
12330 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12331 .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);}
12332 .action-card.test-metrics .action-card-cta{color:#be185d;}
12333 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12334 .action-card:hover .action-card-cta{gap:12px;}
12335 .action-card.card-split{flex-direction:row;align-items:stretch;}
12336 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12337 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12338 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12339 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12340 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12341 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12342 .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;}
12343 .ac-badge.active{opacity:1;}
12344 .ac-badge.github{border-color:#555;color:#555;}
12345 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12346 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12347 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12348 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12349 body.dark-theme .ac-right-row{color:var(--muted);}
12350 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12351 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12352 .divider{height:1px;background:var(--line);margin:32px 0;}
12353 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12354 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12355 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12356 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12357 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12358 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12359 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12360 body.dark-theme .info-chip-val{color:var(--oxide);}
12361 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12362 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12363 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12364 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12365 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12366 border:6px solid transparent;border-top-color:var(--text);}
12367 .info-chip:hover .info-chip-tip{display:block;}
12368 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12369 .chip-slide.fading{filter:blur(5px);opacity:0;}
12370 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12371 .site-footer a{color:var(--muted);}
12372 .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;}
12373 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12374 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12375 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12376 .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;}
12377 .lan-badge.local{background:var(--oxide-2);}
12378 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12379 .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);}
12380 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12381 .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;}
12382 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12383 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12384 .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;}
12385 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12386 .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;}
12387 .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);}
12388 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12389 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12390 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12391 .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;}
12392 @media (max-height: 1100px) {
12393 .page{padding-top:10px;}
12394 .hero{margin-bottom:10px;}
12395 .hero-logo{width:54px;height:60px;}
12396 .hero-logo-shadow{width:42px;}
12397 .hero-title{font-size:28px;}
12398 .hero-subtitle{font-size:13px;}
12399 .card-sections{gap:16px;margin-bottom:10px;}
12400 .card-section-grid-2,.card-section-grid-3{gap:10px;}
12401 .action-card{padding:8px 15px 8px;}
12402 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
12403 .action-card-icon svg{width:18px;height:18px;}
12404 .action-card-title{font-size:13px;}
12405 .action-card-desc{font-size:11px;margin-bottom:6px;}
12406 .action-card-cta{font-size:11px;}
12407 .ac-right-row{font-size:11px;}
12408 .divider{margin:14px 0;}
12409 .info-strip{gap:7px;margin-bottom:12px;}
12410 .info-chip{padding:7px 10px;}
12411 .info-chip-val{font-size:13px;}
12412 .info-chip-label{font-size:9px;}
12413 .site-footer{padding:8px 24px;font-size:12px;}
12414 }
12415 @media (max-height: 850px) {
12416 .page{padding-top:6px;}
12417 .hero{margin-bottom:6px;}
12418 .hero-logo{width:42px;height:46px;}
12419 .hero-title{font-size:22px;}
12420 .hero-subtitle{font-size:12px;}
12421 .card-sections{gap:10px;}
12422 .action-card-desc{margin-bottom:4px;}
12423 .divider{margin:8px 0;}
12424 .info-strip{margin-bottom:6px;}
12425 .lan-local-hint{margin-top:10px;}
12426 }
12427 </style>
12428</head>
12429<body>
12430 <div class="background-watermarks" aria-hidden="true">
12431 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12432 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12433 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12434 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12435 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12436 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12437 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12438 </div>
12439 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12440 <div class="top-nav">
12441 <div class="top-nav-inner">
12442 <a class="brand" href="/">
12443 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12444 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12445 </a>
12446 <div class="nav-right">
12447 <a class="nav-pill" href="/">Home</a>
12448 <div class="nav-dropdown">
12449 <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>
12450 <div class="nav-dropdown-menu">
12451 <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>
12452 </div>
12453 </div>
12454 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12455 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12456 <div class="nav-dropdown">
12457 <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>
12458 <div class="nav-dropdown-menu">
12459 <a href="/webhook-setup"><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>
12460 </div>
12461 </div>
12462 <div class="server-status-wrap">
12463 {% if server_mode %}
12464 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12465 <div class="server-status-tip">OxideSLOC is running in server mode — accessible on your LAN.<br>Use Ctrl+C in the terminal to stop.</div>
12466 {% else %}
12467 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12468 <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12469 {% endif %}
12470 </div>
12471 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12472 <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>
12473 </button>
12474 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12475 <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>
12476 <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>
12477 </button>
12478 </div>
12479 </div>
12480 </div>
12481
12482 <div class="page">
12483 <div class="hero">
12484 <div class="hero-logo-wrap" id="hero-logo-wrap">
12485 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12486 </div>
12487 <div class="hero-logo-shadow"></div>
12488 <div class="hero-title-wrap">
12489 <div class="hero-title-aura" aria-hidden="true"></div>
12490 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12491 </div>
12492 <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>
12493 </div>
12494
12495 <div class="card-sections">
12496
12497 <div>
12498 <div class="card-section-label">Analysis</div>
12499 <div class="card-section-grid-2">
12500 <a class="action-card scan card-split" href="/scan-setup">
12501 <div class="action-card-left">
12502 <div class="action-card-icon">
12503 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12504 </div>
12505 <div class="action-card-title">Scan Project</div>
12506 <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>
12507 <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>
12508 </div>
12509 <div class="action-card-sep"></div>
12510 <div class="action-card-right">
12511 <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>
12512 <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>
12513 <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>
12514 <div class="ac-right-stat" id="acp-scan-stat"></div>
12515 </div>
12516 </a>
12517 <a class="action-card test-metrics card-split" href="/test-metrics">
12518 <div class="action-card-left">
12519 <div class="action-card-icon">
12520 <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>
12521 </div>
12522 <div class="action-card-title">Test Metrics</div>
12523 <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>
12524 <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>
12525 </div>
12526 <div class="action-card-sep"></div>
12527 <div class="action-card-right">
12528 <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>
12529 <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>
12530 <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>
12531 <div class="ac-right-stat" id="acp-test-stat"></div>
12532 </div>
12533 </a>
12534 </div>
12535 </div>
12536
12537 <div>
12538 <div class="card-section-label">Reports & Insights</div>
12539 <div class="card-section-grid-3">
12540 <a class="action-card view" href="/view-reports">
12541 <div class="action-card-icon">
12542 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12543 </div>
12544 <div class="action-card-title">View Reports</div>
12545 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12546 <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>
12547 </a>
12548 <a class="action-card compare" href="/compare-scans">
12549 <div class="action-card-icon">
12550 <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>
12551 </div>
12552 <div class="action-card-title">Compare Scans</div>
12553 <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>
12554 <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>
12555 </a>
12556 <a class="action-card trend" href="/trend-reports">
12557 <div class="action-card-icon">
12558 <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>
12559 </div>
12560 <div class="action-card-title">Trend Report</div>
12561 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12562 <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>
12563 </a>
12564 </div>
12565 </div>
12566
12567 <div>
12568 <div class="card-section-label">Developer Tools</div>
12569 <div class="card-section-grid-2">
12570 <a class="action-card git-tools card-split" href="/git-browser">
12571 <div class="action-card-left">
12572 <div class="action-card-icon">
12573 <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>
12574 </div>
12575 <div class="action-card-title">Git Browser</div>
12576 <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>
12577 <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>
12578 </div>
12579 <div class="action-card-sep"></div>
12580 <div class="action-card-right">
12581 <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>
12582 <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>
12583 <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>
12584 </div>
12585 </a>
12586 <a class="action-card automation card-split" href="/integrations">
12587 <div class="action-card-left">
12588 <div class="action-card-icon">
12589 <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>
12590 </div>
12591 <div class="action-card-title">Integrations</div>
12592 <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>
12593 <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>
12594 </div>
12595 <div class="action-card-sep"></div>
12596 <div class="action-card-right">
12597 <div class="ac-badges-grid">
12598 <span class="ac-badge github" id="acp-gh">GitHub</span>
12599 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
12600 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
12601 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12602 </div>
12603 <div class="ac-right-stat" id="acp-int-stat"></div>
12604 </div>
12605 </a>
12606 </div>
12607 </div>
12608
12609 </div>
12610
12611 {% if server_mode %}
12612 <div class="lan-card server">
12613 <div class="lan-card-header">
12614 <span class="lan-badge">LAN server</span>
12615 Accessible on your network
12616 </div>
12617 {% if let Some(ip) = lan_ip %}
12618 <div class="lan-url-row">
12619 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12620 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12621 <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>
12622 Copy URL
12623 </button>
12624 </div>
12625 <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12626 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
12627 {% else %}
12628 <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>.</p>
12629 {% endif %}
12630 </div>
12631 {% endif %}
12632
12633 <div class="divider"></div>
12634
12635 <div class="info-strip">
12636 <div class="info-chip">
12637 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12638 <div class="chip-slide">
12639 <div class="info-chip-val">41</div>
12640 <div class="info-chip-label">Languages</div>
12641 </div>
12642 </div>
12643 <div class="info-chip">
12644 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12645 <div class="chip-slide">
12646 <div class="info-chip-val">100%</div>
12647 <div class="info-chip-label">Self-contained</div>
12648 </div>
12649 </div>
12650 <div class="info-chip">
12651 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12652 <div class="chip-slide">
12653 <div class="info-chip-val">HTML+PDF</div>
12654 <div class="info-chip-label">Exportable reports</div>
12655 </div>
12656 </div>
12657 <div class="info-chip">
12658 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12659 <div class="chip-slide">
12660 <div class="info-chip-val">Webhook</div>
12661 <div class="info-chip-label">3 platforms</div>
12662 </div>
12663 </div>
12664 <div class="info-chip">
12665 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12666 <div class="chip-slide">
12667 <div class="info-chip-val">IEEE</div>
12668 <div class="info-chip-label">1045-1992</div>
12669 </div>
12670 </div>
12671 </div>
12672
12673 {% if lan_ip.is_none() %}
12674 <div class="lan-local-hint">
12675 <strong>Want teammates on the same network to access this?</strong><br>
12676 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
12677 </div>
12678 {% endif %}
12679 </div>
12680
12681 <footer class="site-footer">
12682 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12683 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12684 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12685 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12686 · <a href="/api-docs" rel="noopener">REST API</a>
12687 </footer>
12688
12689 <script nonce="{{ csp_nonce }}">
12690 (function () {
12691 var storageKey = 'oxide-sloc-theme';
12692 var body = document.body;
12693 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12694 var toggle = document.getElementById('theme-toggle');
12695 if (toggle) toggle.addEventListener('click', function () {
12696 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12697 body.classList.toggle('dark-theme', next === 'dark');
12698 try { localStorage.setItem(storageKey, next); } catch(e) {}
12699 });
12700 var copyBtn = document.getElementById('lan-copy-btn');
12701 if (copyBtn) copyBtn.addEventListener('click', function() {
12702 var btn = this;
12703 var el = document.getElementById('lan-url-val');
12704 if (!el) return;
12705 var url = el.textContent.trim();
12706 if (navigator.clipboard) {
12707 navigator.clipboard.writeText(url).then(function() {
12708 var orig = btn.innerHTML;
12709 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!';
12710 setTimeout(function() { btn.innerHTML = orig; }, 1800);
12711 });
12712 }
12713 });
12714 (function randomizeWatermarks() {
12715 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12716 if (!wms.length) return;
12717 var placed = [];
12718 function tooClose(top, left) {
12719 for (var i = 0; i < placed.length; i++) {
12720 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12721 if (dt < 16 && dl < 12) return true;
12722 }
12723 return false;
12724 }
12725 function pick(leftBand) {
12726 for (var attempt = 0; attempt < 50; attempt++) {
12727 var top = Math.random() * 88 + 2;
12728 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12729 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12730 }
12731 var top = Math.random() * 88 + 2;
12732 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12733 placed.push([top, left]); return [top, left];
12734 }
12735 var half = Math.floor(wms.length / 2);
12736 wms.forEach(function (img, i) {
12737 var pos = pick(i < half);
12738 var size = Math.floor(Math.random() * 100 + 120);
12739 var rot = (Math.random() * 360).toFixed(1);
12740 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12741 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;
12742 });
12743 })();
12744
12745 (function spawnCodeParticles() {
12746 var container = document.getElementById('code-particles');
12747 if (!container) return;
12748 var snippets = [
12749 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12750 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12751 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12752 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12753 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12754 ];
12755 var count = 38;
12756 for (var i = 0; i < count; i++) {
12757 (function(idx) {
12758 var el = document.createElement('span');
12759 el.className = 'code-particle';
12760 var text = snippets[idx % snippets.length];
12761 el.textContent = text;
12762 var left = Math.random() * 94 + 2;
12763 var top = Math.random() * 88 + 6;
12764 var dur = (Math.random() * 10 + 9).toFixed(1);
12765 var delay = (Math.random() * 18).toFixed(1);
12766 var rot = (Math.random() * 26 - 13).toFixed(1);
12767 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12768 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12769 + '--rot:' + rot + 'deg;--op:' + op + ';'
12770 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12771 container.appendChild(el);
12772 })(i);
12773 }
12774 })();
12775 (function heroAnimations() {
12776 var sub = document.getElementById('hero-subtitle');
12777 if (sub) {
12778 var full = sub.textContent.trim();
12779 sub.textContent = '';
12780 sub.style.opacity = '1';
12781 var cursor = document.createElement('span');
12782 cursor.className = 'hero-cursor';
12783 sub.appendChild(cursor);
12784 var i = 0;
12785 setTimeout(function() {
12786 var iv = setInterval(function() {
12787 if (i < full.length) {
12788 sub.insertBefore(document.createTextNode(full[i]), cursor);
12789 i++;
12790 } else {
12791 clearInterval(iv);
12792 setTimeout(function() {
12793 cursor.style.transition = 'opacity 1s ease';
12794 cursor.style.opacity = '0';
12795 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
12796 }, 2400);
12797 }
12798 }, 11);
12799 }, 374);
12800 }
12801 })();
12802 (function logoBob() {
12803 var logo = document.querySelector('.hero-logo');
12804 var shadow = document.querySelector('.hero-logo-shadow');
12805 if (!logo) return;
12806 var cycleStart = null, cycleDur = 3600;
12807 var peakY = -14, peakScale = 1.07, peakRot = 0;
12808 function newCycle() {
12809 cycleDur = 3000 + Math.random() * 1840;
12810 peakY = -(9 + Math.random() * 13.8);
12811 peakScale = 1.04 + Math.random() * 0.081;
12812 peakRot = (Math.random() * 11.5 - 5.75);
12813 }
12814 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
12815 newCycle();
12816 function frame(ts) {
12817 if (cycleStart === null) cycleStart = ts;
12818 var t = (ts - cycleStart) / cycleDur;
12819 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
12820 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
12821 var y = peakY * phase;
12822 var sc = 1 + (peakScale - 1) * phase;
12823 var rot = peakRot * Math.sin(Math.PI * phase);
12824 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
12825 if (shadow) {
12826 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
12827 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
12828 }
12829 requestAnimationFrame(frame);
12830 }
12831 requestAnimationFrame(frame);
12832 })();
12833 (function mouseEffects() {
12834 var heroTitle = document.getElementById('hero-title');
12835 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
12836 function tick() {
12837 raf = null;
12838 if (heroTitle) {
12839 var r = heroTitle.getBoundingClientRect();
12840 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
12841 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
12842 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
12843 }
12844 }
12845 document.addEventListener('mousemove', function(e) {
12846 mx = e.clientX; my = e.clientY;
12847 if (!raf) raf = requestAnimationFrame(tick);
12848 });
12849 document.addEventListener('mouseleave', function() {
12850 if (heroTitle) {
12851 heroTitle.style.transition = 'transform 0.5s ease';
12852 heroTitle.style.transform = '';
12853 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
12854 }
12855 });
12856 document.querySelectorAll('.action-card').forEach(function(card) {
12857 card.addEventListener('mousemove', function(e) {
12858 var rect = card.getBoundingClientRect();
12859 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
12860 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
12861 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
12862 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
12863 });
12864 card.addEventListener('mouseleave', function() {
12865 card.style.transition = '';
12866 card.style.transform = '';
12867 });
12868 });
12869 })();
12870 (function chipSlideshow() {
12871 var slides = [
12872 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
12873 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
12874 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
12875 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
12876 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
12877 ];
12878 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
12879 var indices = [0,0,0,0,0];
12880 var paused = [false,false,false,false,false];
12881 chips.forEach(function(chip, i) {
12882 chip.addEventListener('mouseenter', function() { paused[i] = true; });
12883 chip.addEventListener('mouseleave', function() { paused[i] = false; });
12884 });
12885 function advance(i) {
12886 if (paused[i]) return;
12887 var chip = chips[i];
12888 var inner = chip.querySelector('.chip-slide');
12889 if (!inner) return;
12890 inner.classList.add('fading');
12891 setTimeout(function() {
12892 indices[i] = (indices[i] + 1) % slides[i].length;
12893 var s = slides[i][indices[i]];
12894 chip.querySelector('.info-chip-val').textContent = s.v;
12895 chip.querySelector('.info-chip-label').textContent = s.l;
12896 inner.classList.remove('fading');
12897 }, 720);
12898 }
12899 setInterval(function() {
12900 chips.forEach(function(chip, i) { advance(i); });
12901 }, 6000);
12902 })();
12903 (function cardLiveData() {
12904 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
12905 var el = document.getElementById('acp-scan-stat');
12906 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
12907 }).catch(function(){});
12908 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
12909 var el = document.getElementById('acp-test-stat');
12910 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
12911 }).catch(function(){});
12912 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
12913 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
12914 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
12915 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
12916 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
12917 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
12918 var stat = document.getElementById('acp-int-stat');
12919 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
12920 }).catch(function(){});
12921 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
12922 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
12923 }).catch(function(){});
12924 })();
12925 })();
12926 </script>
12927 <script nonce="{{ csp_nonce }}">
12928 (function(){
12929 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'}];
12930 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);});}
12931 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12932 function init(){
12933 var btn=document.getElementById('settings-btn');if(!btn)return;
12934 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12935 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>';
12936 document.body.appendChild(m);
12937 var g=document.getElementById('scheme-grid');
12938 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);});
12939 var cl=document.getElementById('settings-close');
12940 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);
12941 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');});
12942 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12943 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12944 }
12945 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12946 }());
12947 </script>
12948</body>
12949</html>
12950"##,
12951 ext = "html"
12952)]
12953struct SplashTemplate {
12954 csp_nonce: String,
12955 server_mode: bool,
12956 lan_ip: Option<String>,
12957 port: u16,
12958 version: &'static str,
12959}
12960
12961#[derive(Template)]
12964#[template(
12965 source = r##"
12966<!doctype html>
12967<html lang="en">
12968<head>
12969 <meta charset="utf-8">
12970 <meta name="viewport" content="width=device-width, initial-scale=1">
12971 <title>OxideSLOC — Start a Scan</title>
12972 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12973 <style nonce="{{ csp_nonce }}">
12974 :root {
12975 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
12976 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12977 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12978 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12979 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12980 }
12981 body.dark-theme {
12982 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12983 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12984 }
12985 *{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);}
12986 .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);}
12987 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12988 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
12989 .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));}
12990 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
12991 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
12992 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
12993 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12994 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12995 @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; } }
12996 .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;}
12997 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12998 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12999 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13000 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13001 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13002 .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;}
13003 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13004 .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);}
13005 .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;}
13006 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13007 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13008 .settings-modal-body{padding:14px 16px 16px;}
13009 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13010 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13011 .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;}
13012 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13013 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13014 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13015 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13016 .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;}
13017 .tz-select:focus{border-color:var(--oxide);}
13018 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13019 .page-header{text-align:center;margin-bottom:16px;}
13020 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13021 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13022 /* Cards */
13023 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13024 .option-card-wrap{position:relative;}
13025 .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;}
13026 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13027 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13028 .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;}
13029 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13030 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13031 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13032 .card-top-row{display:flex;align-items:center;gap:20px;}
13033 /* Two-column layout inside each card */
13034 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13035 .card-left{display:flex;align-items:flex-start;min-width:0;}
13036 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13037 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13038 .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);}
13039 .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);}
13040 .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);}
13041 .card-text{min-width:0;}
13042 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13043 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13044 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13045 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13046 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13047 /* Right CTA column */
13048 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13049 .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;}
13050 /* Re-scan count badge */
13051 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13052 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13053 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13054 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13055 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13056 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13057 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13058 body.dark-theme .btn-secondary{color:var(--oxide);}
13059 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13060 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13061 /* File input overlay — must be full-width so it aligns with other card-right buttons */
13062 .file-input-wrap{position:relative;width:100%;}
13063 .file-input-wrap .btn{width:100%;}
13064 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13065 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13066 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13067 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13068 .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;}
13069 @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));}}
13070 /* Recent list (card 3 — full-width section below header) */
13071 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13072 .recent-list{display:flex;flex-direction:column;gap:8px;}
13073 .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;}
13074 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13075 .recent-item-info{flex:1;min-width:0;}
13076 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13077 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13078 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13079 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13080 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13081 .site-footer a{color:var(--muted);}
13082 @media(max-width:680px){
13083 .card-body{grid-template-columns:1fr;}
13084 .card-right{flex-direction:row;flex-wrap:wrap;}
13085 .btn{flex:1;}
13086 }
13087 .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;}
13088 .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;}
13089 .server-online-pill{cursor:default;}
13090 </style>
13091</head>
13092<body>
13093 <div class="background-watermarks" aria-hidden="true">
13094 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13095 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13096 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13097 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13098 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13099 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13100 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13101 </div>
13102 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13103 <div class="top-nav">
13104 <div class="top-nav-inner">
13105 <a class="brand" href="/">
13106 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13107 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13108 </a>
13109 <div class="nav-right">
13110 <a class="nav-pill" href="/">Home</a>
13111 <div class="nav-dropdown">
13112 <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>
13113 <div class="nav-dropdown-menu">
13114 <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>
13115 </div>
13116 </div>
13117 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13118 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13119 <div class="nav-dropdown">
13120 <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>
13121 <div class="nav-dropdown-menu">
13122 <a href="/webhook-setup"><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>
13123 </div>
13124 </div>
13125 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13126 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13127 <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>
13128 </button>
13129 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13130 <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>
13131 <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>
13132 </button>
13133 </div>
13134 </div>
13135 </div>
13136
13137 <div class="page">
13138 <div class="page-header">
13139 <h1>How would you like to scan?</h1>
13140 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13141 </div>
13142
13143 <div class="option-grid">
13144
13145 <!-- Option 1: New scan -->
13146 <div class="option-card-wrap">
13147 <div class="option-card">
13148 <div class="option-icon new-scan">
13149 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13150 </div>
13151 <div class="card-body">
13152 <div class="card-left">
13153 <div class="card-text">
13154 <div class="option-title">Start a new scan</div>
13155 <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>
13156 <ul class="feature-list">
13157 <li>Live project scope preview before you run</li>
13158 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13159 <li>HTML, PDF, and JSON output — your choice</li>
13160 </ul>
13161 </div>
13162 </div>
13163 <div class="card-right">
13164 <a class="btn btn-primary" href="/scan">
13165 Configure & scan
13166 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13167 </a>
13168 <p class="card-tip">Full 4-step setup · all options</p>
13169 </div>
13170 </div>
13171 </div>
13172 </div>
13173
13174 <!-- Option 2: Load from config file -->
13175 <div class="option-card-wrap">
13176 <div class="option-card">
13177 <div class="option-icon load-config">
13178 <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>
13179 </div>
13180 <div class="card-body">
13181 <div class="card-left">
13182 <div class="card-text">
13183 <div class="option-title">Load a saved config</div>
13184 <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>
13185 <ul class="feature-list">
13186 <li>All 15 settings restored from the file</li>
13187 <li>Fully editable — change path or output dir</li>
13188 <li>Works with any scan-config.json</li>
13189 </ul>
13190 </div>
13191 </div>
13192 <div class="card-right">
13193 <div class="file-input-wrap">
13194 <button class="btn btn-secondary" id="load-config-btn" type="button">
13195 <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>
13196 Choose config file
13197 </button>
13198 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13199 </div>
13200 <p class="card-tip" id="config-file-name">Exported after every scan</p>
13201 </div>
13202 </div>
13203 </div>
13204 </div>
13205
13206 <!-- Option 3: Re-scan recent project -->
13207 <div class="option-card-wrap">
13208 <div class="option-card" id="recent-card">
13209 <div class="card-top-row">
13210 <div class="option-icon rescan">
13211 <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>
13212 </div>
13213 <div class="card-body">
13214 <div class="card-left">
13215 <div class="card-text">
13216 <div class="option-title">Re-scan a recent project</div>
13217 <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>
13218 <ul class="feature-list">
13219 <li>All 15+ settings restored from the saved config</li>
13220 <li>Path and output dir are editable before running</li>
13221 <li>Only scans with a saved config appear here</li>
13222 </ul>
13223 </div>
13224 </div>
13225 <div class="card-right">
13226 <div class="rescan-count-box">
13227 <div class="rescan-count-num" id="rescan-count-num">—</div>
13228 <div class="rescan-count-label">saved configs</div>
13229 </div>
13230 <a class="btn btn-secondary" href="/view-reports">
13231 <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>
13232 View all runs
13233 </a>
13234 <p class="card-tip">Opens run history</p>
13235 </div>
13236 </div>
13237 </div>
13238 <div class="section-divider"></div>
13239 <div class="recent-list" id="recent-list">
13240 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13241 </div>
13242 </div>
13243 </div>
13244
13245 </div>
13246 </div>
13247
13248 <footer class="site-footer">
13249 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
13250 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13251 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13252 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13253 · <a href="/api-docs" rel="noopener">REST API</a>
13254 </footer>
13255
13256 <script nonce="{{ csp_nonce }}">
13257 (function () {
13258 var storageKey = 'oxide-sloc-theme';
13259 var body = document.body;
13260 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13261 var toggle = document.getElementById('theme-toggle');
13262 if (toggle) toggle.addEventListener('click', function () {
13263 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13264 body.classList.toggle('dark-theme', next === 'dark');
13265 try { localStorage.setItem(storageKey, next); } catch(e) {}
13266 });
13267
13268 (function randomizeWatermarks() {
13269 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13270 if (!wms.length) return;
13271 var placed = [];
13272 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; }
13273 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]; }
13274 var half = Math.floor(wms.length / 2);
13275 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; });
13276 })();
13277 (function spawnCodeParticles() {
13278 var container = document.getElementById('code-particles');
13279 if (!container) return;
13280 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'];
13281 var count = 38;
13282 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); }
13283 })();
13284 // Recent scans data injected from server
13285 var recentScans = {{ recent_scans_json|safe }};
13286
13287 function configToParams(cfg) {
13288 var p = new URLSearchParams();
13289 p.set('prefilled', '1');
13290 if (cfg.path) p.set('path', cfg.path);
13291 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13292 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13293 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13294 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13295 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13296 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13297 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13298 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13299 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13300 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13301 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13302 if (cfg.report_title) p.set('report_title', cfg.report_title);
13303 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13304 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13305 return p;
13306 }
13307
13308 // Build recent scan list (capped at 3 visible entries)
13309 var list = document.getElementById('recent-list');
13310 var noNote = document.getElementById('no-recent-note');
13311 var hasAny = false;
13312 var MAX_RECENT = 3;
13313 if (Array.isArray(recentScans)) {
13314 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13315 var shown = 0;
13316 validEntries.forEach(function (entry) {
13317 if (shown >= MAX_RECENT) return;
13318 shown++;
13319 hasAny = true;
13320 var item = document.createElement('div');
13321 item.className = 'recent-item';
13322 item.title = 'Restore all settings and open wizard';
13323 item.innerHTML =
13324 '<div class="recent-item-info">' +
13325 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13326 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
13327 '</div>' +
13328 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13329 item.addEventListener('click', function () {
13330 var params = configToParams(entry.config);
13331 window.location.href = '/scan?' + params.toString();
13332 });
13333 list.appendChild(item);
13334 });
13335 if (validEntries.length > MAX_RECENT) {
13336 var moreEl = document.createElement('div');
13337 moreEl.className = 'recent-more-link';
13338 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
13339 list.appendChild(moreEl);
13340 }
13341 }
13342 if (hasAny && noNote) noNote.style.display = 'none';
13343 // Update count badge
13344 var countEl = document.getElementById('rescan-count-num');
13345 if (countEl) {
13346 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13347 countEl.textContent = total > 0 ? total : '0';
13348 }
13349
13350 // Config file loader
13351 var fileInput = document.getElementById('config-file-input');
13352 var fileName = document.getElementById('config-file-name');
13353 if (fileInput) {
13354 fileInput.addEventListener('change', function () {
13355 var file = fileInput.files && fileInput.files[0];
13356 if (!file) return;
13357 if (fileName) fileName.textContent = '✓ ' + file.name;
13358 var reader = new FileReader();
13359 reader.onload = function (e) {
13360 try {
13361 var cfg = JSON.parse(e.target.result);
13362 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13363 var params = configToParams(cfg);
13364 window.location.href = '/scan?' + params.toString();
13365 } catch (err) {
13366 alert('Could not parse config file: ' + err.message);
13367 }
13368 };
13369 reader.readAsText(file);
13370 });
13371 }
13372
13373 function escHtml(s) {
13374 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
13375 }
13376 })();
13377 </script>
13378 <script nonce="{{ csp_nonce }}">
13379 (function(){
13380 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'}];
13381 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);});}
13382 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13383 function init(){
13384 var btn=document.getElementById('settings-btn');if(!btn)return;
13385 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13386 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>';
13387 document.body.appendChild(m);
13388 var g=document.getElementById('scheme-grid');
13389 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);});
13390 var cl=document.getElementById('settings-close');
13391 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);
13392 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');});
13393 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13394 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13395 }
13396 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13397 }());
13398 </script>
13399</body>
13400</html>
13401"##,
13402 ext = "html"
13403)]
13404struct ScanSetupTemplate {
13405 version: &'static str,
13406 recent_scans_json: String,
13407 csp_nonce: String,
13408}
13409
13410#[derive(Template)]
13411#[template(
13412 source = r##"
13413<!doctype html>
13414<html lang="en">
13415<head>
13416 <meta charset="utf-8">
13417 <meta name="viewport" content="width=device-width, initial-scale=1">
13418 <title>OxideSLOC | {{ report_title }} | Report</title>
13419 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13420 <style nonce="{{ csp_nonce }}">
13421 :root {
13422 --radius: 18px;
13423 --bg: #f5efe8;
13424 --surface: rgba(255,255,255,0.82);
13425 --surface-2: #fbf7f2;
13426 --surface-3: #efe6dc;
13427 --line: #e6d0bf;
13428 --line-strong: #dcb89f;
13429 --text: #43342d;
13430 --muted: #7b675b;
13431 --muted-2: #a08777;
13432 --nav: #b85d33;
13433 --nav-2: #7a371b;
13434 --accent: #6f9bff;
13435 --accent-2: #4a78ee;
13436 --oxide: #d37a4c;
13437 --oxide-2: #b35428;
13438 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13439 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13440 --success-bg: #e8f5ed;
13441 --success-text: #1a8f47;
13442 --info-bg: #eef3ff;
13443 --info-text: #4467d8;
13444 }
13445
13446 body.dark-theme {
13447 --bg: #1b1511;
13448 --surface: #261c17;
13449 --surface-2: #2d221d;
13450 --surface-3: #372922;
13451 --line: #524238;
13452 --line-strong: #6c5649;
13453 --text: #f5ece6;
13454 --muted: #c7b7aa;
13455 --muted-2: #aa9485;
13456 --nav: #b85d33;
13457 --nav-2: #7a371b;
13458 --accent: #6f9bff;
13459 --accent-2: #4a78ee;
13460 --oxide: #d37a4c;
13461 --oxide-2: #b35428;
13462 --shadow: 0 18px 42px rgba(0,0,0,0.28);
13463 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13464 --success-bg: #163927;
13465 --success-text: #8fe2a8;
13466 --info-bg: #1c2847;
13467 --info-text: #a9c1ff;
13468 }
13469
13470 * { box-sizing: border-box; }
13471 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); }
13472 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13473 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13474 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13475 .top-nav, .page { position: relative; z-index: 2; }
13476 .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); }
13477 .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; }
13478 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13479 .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)); }
13480 .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; }
13481 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13482 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13483 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13484 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13485 .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; }
13486 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13487 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13488 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13489 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13490 @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; } }
13491 .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; }
13492 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13493 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13494 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13495 .theme-toggle .icon-sun { display:none; }
13496 body.dark-theme .theme-toggle .icon-sun { display:block; }
13497 body.dark-theme .theme-toggle .icon-moon { display:none; }
13498 .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;}
13499 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13500 .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);}
13501 .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;}
13502 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13503 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13504 .settings-modal-body{padding:14px 16px 16px;}
13505 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13506 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13507 .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;}
13508 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13509 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13510 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13511 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13512 .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;}
13513 .tz-select:focus{border-color:var(--oxide);}
13514 .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; }
13515 .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;}
13516 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13517 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13518 .hero, .panel { padding: 22px; }
13519 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13520 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13521 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13522 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13523 .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; }
13524 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13525 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13526 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13527 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13528 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13529 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13530 .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; }
13531 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13532 .delta-card-val { font-size:16px; font-weight:800; }
13533 .delta-card-val.pos { color:#1e7e34; }
13534 .delta-card-val.neg { color:var(--neg); }
13535 .delta-card-val.mod { color:#b35428; }
13536 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13537 .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; }
13538 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13539 .delta-card-inline:hover .delta-card-tip { opacity:1; }
13540 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13541 .compare-ts { font-size:13px; color:var(--muted); }
13542 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13543 .compare-arrow { color: var(--muted); }
13544 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13545 .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; }
13546 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13547 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13548 .button, .copy-button {
13549 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;
13550 }
13551 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13552 @keyframes spin { to { transform: rotate(360deg); } }
13553 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13554 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13555 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13556 .path-item strong { display: block; margin-bottom: 6px; }
13557 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13558 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13559 .path-subitem { flex: 1; }
13560 .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); }
13561 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); }
13562 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13563 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13564 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13565 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13566 th { color: var(--muted); font-weight: 700; }
13567 tr:last-child td { border-bottom: none; }
13568 #subm-tbl col:nth-child(1){width:15%;}
13569 #subm-tbl col:nth-child(2){width:31%;}
13570 #subm-tbl col:nth-child(3){width:9%;}
13571 #subm-tbl col:nth-child(4){width:9%;}
13572 #subm-tbl col:nth-child(5){width:9%;}
13573 #subm-tbl col:nth-child(6){width:9%;}
13574 #subm-tbl col:nth-child(7){width:9%;}
13575 #subm-tbl col:nth-child(8){width:9%;}
13576 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13577 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13578 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13579 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13580 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13581 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13582 .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; }
13583 .soft-chip.success { gap:7px; padding:0 16px 0 12px; background:linear-gradient(135deg,rgba(26,143,71,0.12),rgba(26,143,71,0.06)); color:var(--success-text); border:1.5px solid rgba(26,143,71,0.35); box-shadow:0 0 0 4px rgba(26,143,71,0.07),0 2px 8px rgba(26,143,71,0.12); font-size:12px; letter-spacing:0.02em; }
13584 .soft-chip.success svg { flex:0 0 auto; }
13585 body.dark-theme .soft-chip.success { background:linear-gradient(135deg,rgba(143,226,168,0.12),rgba(143,226,168,0.05)); border-color:rgba(143,226,168,0.3); box-shadow:0 0 0 4px rgba(143,226,168,0.07),0 2px 8px rgba(0,0,0,0.2); }
13586 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13587 .muted { color: var(--muted); }
13588 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13589 .site-footer a{color:var(--muted);}
13590 .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; }
13591 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13592 .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; }
13593 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13594 /* Stat chips (matches HTML report) */
13595 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13596 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13597 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13598 .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; }
13599 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13600 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13601 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13602 .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; }
13603 .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; }
13604 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13605 .stat-chip:hover .stat-chip-tip { opacity:1; }
13606 /* Submodule panel */
13607 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13608 /* Metrics tables stack */
13609 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13610 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13611 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13612 .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)); }
13613 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13614 /* Metrics table */
13615 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13616 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13617 .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; }
13618 .metrics-table thead th:not(:first-child) { text-align: right; }
13619 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13620 .metrics-table tbody tr:last-child td { border-bottom: none; }
13621 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13622 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13623 .metrics-table tbody tr:hover td { background: var(--surface-2); }
13624 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13625 .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; }
13626 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13627 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13628 .mt-val-pos { color: var(--pos); font-weight: 700; }
13629 .mt-val-neg { color: var(--neg); font-weight: 700; }
13630 .mt-val-zero { color: var(--muted); }
13631 .mt-val-mod { color: var(--oxide-2); }
13632 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13633 @media (max-width: 1180px) {
13634 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13635 .nav-project-slot, .nav-status { justify-content:flex-start; }
13636 .hero-top { flex-direction: column; }
13637 }
13638 .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;}
13639 @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));}}
13640 .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;}
13641 /* ── Result-page chart controls ─────────────────────────────────────────── */
13642 .r-chart-section{margin-bottom:24px;}
13643 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13644 .section-pair > .panel{flex-shrink:0;}
13645 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13646 .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;}
13647 .r-chart-select:focus{border-color:var(--accent);}
13648 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13649 .r-chart-container svg{display:block;width:100%;height:auto;}
13650 .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13651 .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13652 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13653 .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;}
13654 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13655 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13656 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13657 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13658 #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
13659 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13660 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13661 .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;}
13662 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13663 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13664 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13665 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13666 .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%;}
13667 .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%;}
13668 body.has-report-banner .top-nav{top:27px;}
13669 body.has-report-banner{padding-bottom:27px;}
13670 </style>
13671</head>
13672<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13673 <div class="background-watermarks" aria-hidden="true">
13674 <img src="/images/logo/logo-text.png" alt="" />
13675 <img src="/images/logo/logo-text.png" alt="" />
13676 <img src="/images/logo/logo-text.png" alt="" />
13677 <img src="/images/logo/logo-text.png" alt="" />
13678 <img src="/images/logo/logo-text.png" alt="" />
13679 <img src="/images/logo/logo-text.png" alt="" />
13680 <img src="/images/logo/logo-text.png" alt="" />
13681 <img src="/images/logo/logo-text.png" alt="" />
13682 <img src="/images/logo/logo-text.png" alt="" />
13683 <img src="/images/logo/logo-text.png" alt="" />
13684 <img src="/images/logo/logo-text.png" alt="" />
13685 <img src="/images/logo/logo-text.png" alt="" />
13686 <img src="/images/logo/logo-text.png" alt="" />
13687 <img src="/images/logo/logo-text.png" alt="" />
13688 </div>
13689 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13690 {% if let Some(banner) = report_header_footer %}
13691 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13692 {% endif %}
13693 <div class="top-nav">
13694 <div class="top-nav-inner">
13695 <a class="brand" href="/">
13696 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13697 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13698 </a>
13699 <div class="nav-project-slot">
13700 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13701 </div>
13702 <div class="nav-status">
13703 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13704 <div class="nav-dropdown">
13705 <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>
13706 <div class="nav-dropdown-menu">
13707 <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>
13708 </div>
13709 </div>
13710 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13711 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13712 <div class="nav-dropdown">
13713 <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>
13714 <div class="nav-dropdown-menu">
13715 <a href="/webhook-setup"><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>
13716 </div>
13717 </div>
13718 <div class="server-status-wrap">
13719 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13720 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
13721 </div>
13722 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13723 <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>
13724 </button>
13725 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13726 <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>
13727 <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>
13728 </button>
13729 </div>
13730 </div>
13731 </div>
13732
13733 <div class="page">
13734 <section class="hero">
13735 <div class="hero-top">
13736 <div>
13737 <div class="soft-chip success"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
13738 <h1 class="hero-title">{{ report_title }}</h1>
13739 <p class="hero-subtitle">Your HTML, PDF, and JSON artifacts are now saved. Use the quick actions below to view, download, or copy the saved paths for sharing outside oxide-sloc.</p>
13740 </div>
13741 <div class="hero-quick-actions">
13742 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13743 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13744 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13745 </div>
13746 </div>
13747
13748 <div class="summary-strip">
13749 <div class="stat-chip" data-raw="{{ physical_lines }}">
13750 <div class="stat-chip-label">Physical Lines</div>
13751 <div class="stat-chip-val">{{ physical_lines }}</div>
13752 <div class="stat-chip-exact"></div>
13753 <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13754 </div>
13755 <div class="stat-chip" data-raw="{{ code_lines }}">
13756 <div class="stat-chip-label">Code</div>
13757 <div class="stat-chip-val">{{ code_lines }}</div>
13758 <div class="stat-chip-exact"></div>
13759 <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13760 </div>
13761 <div class="stat-chip" data-raw="{{ comment_lines }}">
13762 <div class="stat-chip-label">Comments</div>
13763 <div class="stat-chip-val">{{ comment_lines }}</div>
13764 <div class="stat-chip-exact"></div>
13765 <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13766 </div>
13767 <div class="stat-chip" data-raw="{{ blank_lines }}">
13768 <div class="stat-chip-label">Blank</div>
13769 <div class="stat-chip-val">{{ blank_lines }}</div>
13770 <div class="stat-chip-exact"></div>
13771 <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13772 </div>
13773 <div class="stat-chip" data-raw="{{ files_analyzed }}">
13774 <div class="stat-chip-label">Files Analyzed</div>
13775 <div class="stat-chip-val">{{ files_analyzed }}</div>
13776 <div class="stat-chip-exact"></div>
13777 <div class="stat-chip-tip">Source files successfully parsed and counted</div>
13778 </div>
13779 <div class="stat-chip" data-raw="{{ functions }}">
13780 <div class="stat-chip-label">Functions</div>
13781 <div class="stat-chip-val">{{ functions }}</div>
13782 <div class="stat-chip-exact"></div>
13783 <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
13784 </div>
13785 </div>
13786
13787 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
13788 <div class="compare-banner">
13789 <div class="compare-banner-body">
13790 <div class="compare-banner-meta">
13791 <span class="compare-label">Previous scan</span>
13792 <span class="compare-ts">{{ prev_ts }}</span>
13793 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
13794 {% if let Some(prev_code) = prev_run_code_lines %}
13795 <div class="compare-banner-stats" style="margin-top:4px;">
13796 <span>Code before: <strong>{{ prev_code }}</strong></span>
13797 <span class="compare-arrow">→</span>
13798 <span>Code now: <strong>{{ code_lines }}</strong></span>
13799 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
13800 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
13801 </div>
13802 {% endif %}
13803 </div>
13804 {% if delta_lines_added.is_some() %}
13805 <div class="delta-cards-inline">
13806 <div class="delta-card-inline">
13807 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
13808 <div class="delta-card-lbl">lines added</div>
13809 <div class="delta-card-tip">Code lines added since the previous scan</div>
13810 </div>
13811 <div class="delta-card-inline">
13812 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
13813 <div class="delta-card-lbl">lines removed</div>
13814 <div class="delta-card-tip">Code lines removed since the previous scan</div>
13815 </div>
13816 <div class="delta-card-inline">
13817 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
13818 <div class="delta-card-lbl">unmodified lines</div>
13819 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
13820 </div>
13821 <div class="delta-card-inline">
13822 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
13823 <div class="delta-card-lbl">files modified</div>
13824 <div class="delta-card-tip">Files with at least one line changed</div>
13825 </div>
13826 <div class="delta-card-inline">
13827 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
13828 <div class="delta-card-lbl">files added</div>
13829 <div class="delta-card-tip">New files added since the previous scan</div>
13830 </div>
13831 <div class="delta-card-inline">
13832 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
13833 <div class="delta-card-lbl">files removed</div>
13834 <div class="delta-card-tip">Files deleted since the previous scan</div>
13835 </div>
13836 <div class="delta-card-inline">
13837 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
13838 <div class="delta-card-lbl">files unchanged</div>
13839 <div class="delta-card-tip">Files with no changes since the previous scan</div>
13840 </div>
13841 </div>
13842 {% else %}
13843 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
13844 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
13845 </p>
13846 {% endif %}
13847 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
13848 </div>
13849 </div>
13850 {% endif %}{% endif %}
13851
13852 <div class="action-grid">
13853 <div class="action-card">
13854 <h3>HTML report</h3>
13855 <div class="action-buttons">
13856 {% match html_url %}
13857 {% when Some with (url) %}
13858 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
13859 {% when None %}{% endmatch %}
13860 {% match html_download_url %}
13861 {% when Some with (url) %}
13862 <a class="button secondary" href="{{ url }}">Download HTML</a>
13863 {% when None %}{% endmatch %}
13864 {% match html_path %}
13865 {% when Some with (_path) %}{% when None %}{% endmatch %}
13866 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
13867 </div>
13868 </div>
13869 <div class="action-card">
13870 <h3>PDF report</h3>
13871 <div class="action-buttons">
13872 {% match pdf_url %}
13873 {% when Some with (url) %}
13874 {% if pdf_generating %}
13875 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
13876 <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>
13877 Generating PDF…
13878 </button>
13879 {% else %}
13880 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
13881 {% endif %}
13882 {% when None %}{% endmatch %}
13883 {% match pdf_download_url %}
13884 {% when Some with (url) %}
13885 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
13886 {% when None %}{% endmatch %}
13887 {% match pdf_path %}
13888 {% when Some with (_path) %}{% when None %}{% endmatch %}
13889 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
13890 </div>
13891 </div>
13892 <div class="action-card">
13893 <h3>JSON result</h3>
13894 <div class="action-buttons">
13895 {% match json_url %}
13896 {% when Some with (url) %}
13897 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
13898 {% when None %}{% endmatch %}
13899 {% match json_download_url %}
13900 {% when Some with (url) %}
13901 <a class="button secondary" href="{{ url }}">Download JSON</a>
13902 {% when None %}{% endmatch %}
13903 {% match json_path %}
13904 {% when Some with (_path) %}
13905 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
13906 {% when None %}
13907 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
13908 {% endmatch %}
13909 </div>
13910 </div>
13911 <div class="action-card">
13912 <h3>Scan config</h3>
13913 <div class="action-buttons">
13914 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
13915 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
13916 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
13917 </div>
13918 </div>
13919 {% if confluence_configured %}
13920 <div class="action-card" id="confluenceCard">
13921 <h3>Confluence</h3>
13922 <div class="action-buttons">
13923 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
13924 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
13925 </div>
13926 <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>
13927 </div>
13928 {% endif %}
13929 </div>
13930 {% if confluence_configured %}
13931 <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;">
13932 <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);">
13933 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
13934 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
13935 <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;">
13936 <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>
13937 <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;">
13938 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
13939 <div style="display:flex;gap:10px;justify-content:flex-end;">
13940 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
13941 <button class="button" id="confSubmitBtn" type="button">Post</button>
13942 </div>
13943 </div>
13944 </div>
13945 {% endif %}
13946 {% if !submodule_rows.is_empty() %}
13947 <div class="submodule-panel">
13948 <div class="toolbar-row">
13949 <div>
13950 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
13951 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
13952 </div>
13953 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
13954 </div>
13955 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
13956 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
13957 <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>
13958 <thead>
13959 <tr>
13960 <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>
13961 <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>
13962 <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>
13963 <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>
13964 <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>
13965 <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>
13966 <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>
13967 <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>
13968 </tr>
13969 </thead>
13970 <tbody>
13971 {% for row in submodule_rows %}
13972 <tr>
13973 <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>
13974 <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>
13975 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
13976 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
13977 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
13978 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
13979 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
13980 <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>
13981 </tr>
13982 {% endfor %}
13983 </tbody>
13984 </table>
13985 </div>
13986 </div>
13987 {% endif %}
13988
13989 <div class="metrics-tables-stack">
13990
13991 <div class="metrics-table-wrap">
13992 <div class="metrics-table-title">Files</div>
13993 <table class="metrics-table">
13994 <thead>
13995 <tr>
13996 <th>Metric</th>
13997 <th>This Run</th>
13998 <th>Previous</th>
13999 <th>Change</th>
14000 </tr>
14001 </thead>
14002 <tbody>
14003 <tr>
14004 <td>Files analyzed</td>
14005 <td class="mt-val-large">{{ files_analyzed }}</td>
14006 <td>{{ prev_fa_str }}</td>
14007 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14008 </tr>
14009 <tr>
14010 <td>Files skipped</td>
14011 <td>{{ files_skipped }}</td>
14012 <td>{{ prev_fs_str }}</td>
14013 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14014 </tr>
14015 <tr>
14016 <td>Files modified</td>
14017 <td class="mt-val-na">—</td>
14018 <td class="mt-val-na">—</td>
14019 <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>
14020 </tr>
14021 <tr>
14022 <td>Files unchanged</td>
14023 <td class="mt-val-na">—</td>
14024 <td class="mt-val-na">—</td>
14025 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14026 </tr>
14027 </tbody>
14028 </table>
14029 </div>
14030
14031 <div class="metrics-table-wrap">
14032 <div class="metrics-table-title">Line Counts</div>
14033 <table class="metrics-table">
14034 <thead>
14035 <tr>
14036 <th>Metric</th>
14037 <th>This Run</th>
14038 <th>Previous</th>
14039 <th>Change</th>
14040 </tr>
14041 </thead>
14042 <tbody>
14043 <tr>
14044 <td>Physical lines</td>
14045 <td class="mt-val-large">{{ physical_lines }}</td>
14046 <td>{{ prev_pl_str }}</td>
14047 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14048 </tr>
14049 <tr>
14050 <td>Code lines</td>
14051 <td class="mt-val-large">{{ code_lines }}</td>
14052 <td>{{ prev_cl_str }}</td>
14053 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14054 </tr>
14055 <tr>
14056 <td>Comment lines</td>
14057 <td>{{ comment_lines }}</td>
14058 <td>{{ prev_cml_str }}</td>
14059 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14060 </tr>
14061 <tr>
14062 <td>Blank lines</td>
14063 <td>{{ blank_lines }}</td>
14064 <td>{{ prev_bl_str }}</td>
14065 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14066 </tr>
14067 <tr>
14068 <td>Mixed (separate)</td>
14069 <td>{{ mixed_lines }}</td>
14070 <td class="mt-val-na">—</td>
14071 <td class="mt-val-na">—</td>
14072 </tr>
14073 </tbody>
14074 </table>
14075 </div>
14076
14077 <div class="metrics-tables-lower">
14078 <div class="metrics-table-wrap">
14079 <div class="metrics-table-title">Code Structure</div>
14080 <table class="metrics-table">
14081 <thead>
14082 <tr>
14083 <th>Metric</th>
14084 <th>This Run</th>
14085 </tr>
14086 </thead>
14087 <tbody>
14088 <tr>
14089 <td>Functions</td>
14090 <td>{{ functions }}</td>
14091 </tr>
14092 <tr>
14093 <td>Classes / Types</td>
14094 <td>{{ classes }}</td>
14095 </tr>
14096 <tr>
14097 <td>Variables</td>
14098 <td>{{ variables }}</td>
14099 </tr>
14100 <tr>
14101 <td>Imports</td>
14102 <td>{{ imports }}</td>
14103 </tr>
14104 </tbody>
14105 </table>
14106 </div>
14107
14108 <div class="metrics-table-wrap">
14109 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14110 <table class="metrics-table">
14111 <thead>
14112 <tr>
14113 <th>Metric</th>
14114 <th>Change</th>
14115 </tr>
14116 </thead>
14117 <tbody>
14118 <tr>
14119 <td>Lines added</td>
14120 <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>
14121 </tr>
14122 <tr>
14123 <td>Lines removed</td>
14124 <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>
14125 </tr>
14126 <tr>
14127 <td>Lines modified (net)</td>
14128 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14129 </tr>
14130 <tr>
14131 <td>Lines unmodified</td>
14132 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14133 </tr>
14134 </tbody>
14135 </table>
14136 </div>
14137 </div>
14138
14139 </div>
14140
14141 <div class="path-list">
14142 <div class="path-item">
14143 <div class="path-item-label">Project path</div>
14144 <code>{{ project_path }}</code>
14145 </div>
14146 <div class="path-item">
14147 <div class="path-item-label">Git branch</div>
14148 {% if let Some(branch) = git_branch %}
14149 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14150 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14151 {% else %}
14152 <code style="color:var(--muted)">—</code>
14153 {% endif %}
14154 </div>
14155 <div class="path-item">
14156 <div class="path-item-label">Output folder</div>
14157 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14158 </div>
14159 <div class="path-item">
14160 <div class="path-item-label">Run ID</div>
14161 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14162 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14163 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14164 </div>
14165 </div>
14166 </div>
14167 </section>
14168
14169 <div id="r-tt" aria-hidden="true"></div>
14170
14171 <div class="section-pair">
14172 <section class="panel">
14173 <div class="toolbar-row">
14174 <div>
14175 <h2>Language breakdown</h2>
14176 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14177 </div>
14178 </div>
14179 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14180 </section>
14181
14182 <section class="panel r-chart-section">
14183 <div class="toolbar-row" style="margin-bottom:16px;">
14184 <div>
14185 <h2>Visualizations</h2>
14186 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14187 </div>
14188 </div>
14189
14190 <div class="r-viz-grid">
14191 <div class="r-viz-card">
14192 <p class="r-viz-card-title">Language Composition</p>
14193 <div class="r-chart-tab-bar">
14194 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14195 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14196 </div>
14197 <div class="r-chart-container" id="r-composition-chart"></div>
14198 </div>
14199 <div class="r-viz-card">
14200 <p class="r-viz-card-title">Files vs Code Lines</p>
14201 <div class="r-chart-container" id="r-scatter-chart"></div>
14202 </div>
14203 {% if has_semantic_data %}
14204 <div class="r-viz-card">
14205 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14206 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14207 <select class="r-chart-select" id="r-semantic-metric">
14208 <option value="functions">Functions</option>
14209 <option value="classes">Classes</option>
14210 <option value="variables">Variables</option>
14211 <option value="imports">Imports</option>
14212 </select>
14213 </div>
14214 <div class="r-chart-container" id="r-semantic-chart"></div>
14215 </div>
14216 {% endif %}
14217 {% if has_submodule_data %}
14218 <div class="r-viz-card">
14219 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14220 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14221 <select class="r-chart-select" id="r-sub-metric">
14222 <option value="code">Code Lines</option>
14223 <option value="comment">Comments</option>
14224 <option value="blank">Blank Lines</option>
14225 <option value="physical">Physical Lines</option>
14226 <option value="files">Files</option>
14227 </select>
14228 <select class="r-chart-select" id="r-sub-sort">
14229 <option value="desc">Value ↓</option>
14230 <option value="asc">Value ↑</option>
14231 <option value="name">Name A→Z</option>
14232 </select>
14233 </div>
14234 <div class="r-chart-container" id="r-submodule-chart"></div>
14235 </div>
14236 {% endif %}
14237 </div>
14238
14239 </section>
14240 </div>
14241
14242 </div>
14243
14244 <script nonce="{{ csp_nonce }}">
14245 (function () {
14246 var body = document.body;
14247 var themeToggle = document.getElementById('theme-toggle');
14248 var storageKey = 'oxide-sloc-theme';
14249
14250 function applyTheme(theme) {
14251 body.classList.toggle('dark-theme', theme === 'dark');
14252 }
14253
14254 function loadSavedTheme() {
14255 try {
14256 var saved = localStorage.getItem(storageKey);
14257 if (saved === 'dark' || saved === 'light') {
14258 applyTheme(saved);
14259 }
14260 } catch (e) {}
14261 }
14262
14263 if (themeToggle) {
14264 themeToggle.addEventListener('click', function () {
14265 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14266 applyTheme(nextTheme);
14267 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14268 });
14269 }
14270
14271 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14272 button.addEventListener('click', function () {
14273 var value = button.getAttribute('data-copy-value') || '';
14274 if (!value) return;
14275 if (navigator.clipboard && navigator.clipboard.writeText) {
14276 navigator.clipboard.writeText(value).catch(function () {});
14277 }
14278 });
14279 });
14280
14281 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14282 btn.addEventListener('click', function () {
14283 var folder = btn.getAttribute('data-folder') || '';
14284 if (!folder) return;
14285 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14286 });
14287 });
14288
14289 loadSavedTheme();
14290
14291 // ── Compact number formatting for stat chips ──────────────────────────
14292 (function(){
14293 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();}
14294 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14295 var raw=parseInt(chip.getAttribute('data-raw'),10);
14296 if(isNaN(raw))return;
14297 var valEl=chip.querySelector('.stat-chip-val');
14298 if(valEl)valEl.textContent=fmt(raw);
14299 var exactEl=chip.querySelector('.stat-chip-exact');
14300 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14301 });
14302 })();
14303
14304 // ── Shared tooltip for all result-page charts ─────────────────────────
14305 var rTT=(function(){
14306 var el=document.getElementById('r-tt');
14307 if(!el)return{s:function(){},h:function(){},m:function(){}};
14308 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14309 function hide(){el.style.display='none';}
14310 function move(e){
14311 var x=e.clientX+16,y=e.clientY-12;
14312 var r=el.getBoundingClientRect();
14313 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14314 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14315 el.style.left=x+'px';el.style.top=y+'px';
14316 }
14317 return{s:show,h:hide,m:move};
14318 })();
14319 window.rTT=rTT;
14320
14321 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14322 (function(){
14323 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14324 document.addEventListener('mouseover',function(e){
14325 var t=e.target;
14326 while(t&&t.getAttribute){
14327 var l=t.getAttribute('data-ttl');
14328 if(l!==null){
14329 var v=t.getAttribute('data-ttv')||'';
14330 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14331 return;
14332 }
14333 t=t.parentNode;
14334 }
14335 });
14336 document.addEventListener('mouseout',function(e){
14337 var t=e.target;
14338 while(t&&t.getAttribute){
14339 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14340 t=t.parentNode;
14341 }
14342 });
14343 document.addEventListener('mousemove',function(e){
14344 var el=document.getElementById('r-tt');
14345 if(el&&el.style.display!=='none')rTT.m(e);
14346 });
14347 })();
14348
14349 // ── Language overview charts ───────────────────────────────────────────
14350 (function(){
14351 var D={{ lang_chart_json|safe }};
14352 if(!D||!D.length)return;
14353 var el=document.getElementById('result-lang-charts');
14354 if(!el)return;
14355 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14356 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14357 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14358 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();}
14359 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14360 function px(n){return Math.round(n);}
14361 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+'"';}
14362 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14363
14364 // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14365 var cx=100,cy=110,Ro=88,Ri=48;
14366 var legX=204,DW=360,DH=220;
14367 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">';
14368 if(D.length===1){
14369 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14370 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+'"/>';
14371 } else {
14372 var ang=-Math.PI/2;
14373 D.forEach(function(d,i){
14374 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14375 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14376 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14377 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14378 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14379 var pct=Math.round(d.code/tot*100);
14380 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"/>';
14381 ang+=sw;
14382 });
14383 }
14384 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14385 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14386 var legRows=Math.min(D.length,8);
14387 var legYStart=Math.round((DH-legRows*22)/2);
14388 D.forEach(function(d,i){
14389 if(i>=8)return;
14390 var ly=legYStart+i*22;
14391 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14392 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14393 });
14394 ds+='</svg>';
14395
14396 // Horizontal stacked-bar chart — fills container width
14397 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14398 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14399 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">';
14400 D.forEach(function(d,i){
14401 var y=6+i*rHb,x=LW;
14402 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14403 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>';
14404 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;
14405 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;
14406 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"/>';
14407 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>';
14408 });
14409 var ly=SH-14;
14410 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>';
14411 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>';
14412 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>';
14413 bs+='</svg>';
14414 el.innerHTML='<div class="r-lang-overview">'+
14415 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14416 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14417 '</div>';
14418 })();
14419
14420 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14421 (function(){
14422 var LANG_D={{ lang_chart_json|safe }};
14423 var SCAT_D={{ scatter_chart_json|safe }};
14424 var SEM_D={{ semantic_chart_json|safe }};
14425 var SUB_D={{ submodule_chart_json|safe }};
14426 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14427 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14428 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();}
14429 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14430 function px(n){return Math.round(n);}
14431 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+'"';}
14432
14433 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14434 function renderComposition(mode){
14435 var el=document.getElementById('r-composition-chart');
14436 if(!el||!LANG_D||!LANG_D.length)return;
14437 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14438 var LW=110,SH=224;
14439 var svgW=Math.max(320,el.offsetWidth||480);
14440 var BW=Math.max(120,svgW-LW-80);
14441 var legendH=24,topPad=4;
14442 var n=LANG_D.length||1;
14443 var rowTotal=Math.floor((SH-legendH-topPad)/n);
14444 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14445 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">';
14446 if(mode==='pct'){
14447 LANG_D.forEach(function(d,i){
14448 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14449 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14450 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14451 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>';
14452 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;
14453 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;
14454 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+'"/>';
14455 var pct=Math.round((d.code||0)/tot2*100);
14456 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14457 });
14458 } else {
14459 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14460 LANG_D.forEach(function(d,i){
14461 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14462 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14463 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>';
14464 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;
14465 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;
14466 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+'"/>';
14467 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>';
14468 });
14469 }
14470 var ly=SH-legendH+4;
14471 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>';
14472 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>';
14473 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>';
14474 s+='</svg>';
14475 el.innerHTML=s;
14476 }
14477 renderComposition('abs');
14478 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14479 btn.addEventListener('click',function(){
14480 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14481 btn.classList.add('active');
14482 renderComposition(btn.getAttribute('data-rcomp'));
14483 });
14484 });
14485
14486 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14487 (function(){
14488 var el=document.getElementById('r-scatter-chart');
14489 if(!el||!SCAT_D||!SCAT_D.length)return;
14490 var H=224,PL=52,PB=36,PT=12,PR=14;
14491 var W=Math.max(320,el.offsetWidth||480);
14492 var cW=W-PL-PR,cH=H-PT-PB;
14493 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14494 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14495 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14496 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">';
14497 [0,0.25,0.5,0.75,1].forEach(function(t){
14498 var y=PT+cH*(1-t);
14499 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14500 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>';
14501 });
14502 [0,0.25,0.5,0.75,1].forEach(function(t){
14503 var x=PL+cW*t;
14504 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14505 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>';
14506 });
14507 SCAT_D.forEach(function(d,i){
14508 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14509 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14510 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"/>';
14511 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>';
14512 });
14513 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>';
14514 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>';
14515 s+='</svg>';
14516 el.innerHTML=s;
14517 })();
14518
14519 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14520 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14521 // the old vertical column layout on wide containers.
14522 function renderSemantic(key){
14523 var el=document.getElementById('r-semantic-chart');
14524 if(!el||!SEM_D||!SEM_D.length)return;
14525 var LW=112,SH=224;
14526 var svgW=Math.max(320,el.offsetWidth||480);
14527 var BW=Math.max(120,svgW-LW-80);
14528 var topPad=4,botPad=14;
14529 var n2=SEM_D.length||1;
14530 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14531 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14532 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14533 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">';
14534 SEM_D.forEach(function(d,i){
14535 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14536 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>';
14537 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"/>';
14538 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>';
14539 });
14540 s+='</svg>';
14541 el.innerHTML=s;
14542 }
14543 var semSel=document.getElementById('r-semantic-metric');
14544 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14545
14546 // ── Submodule: horizontal bar chart ────────────────────────────────────
14547 function renderSubmodule(key,sort){
14548 var el=document.getElementById('r-submodule-chart');
14549 if(!el||!SUB_D||!SUB_D.length)return;
14550 var data=SUB_D.slice();
14551 if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14552 else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14553 else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14554 var LW=128,SH=224;
14555 var svgW=Math.max(320,el.offsetWidth||480);
14556 var BW=Math.max(120,svgW-LW-80);
14557 var topPad3=4,botPad3=14;
14558 var n3=data.length||1;
14559 var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14560 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14561 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14562 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">';
14563 data.forEach(function(d,i){
14564 var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14565 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.name||d.path||'?')+'</text>';
14566 if(bw>0.5)s+='<rect'+tt(d.name||'?',fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
14567 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>';
14568 });
14569 s+='</svg>';
14570 el.innerHTML=s;
14571 }
14572 var subSel=document.getElementById('r-sub-metric');
14573 var sortSel=document.getElementById('r-sub-sort');
14574 if(subSel){
14575 renderSubmodule('code','desc');
14576 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14577 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14578 }
14579
14580 // Re-render all SVG charts when the window is resized so bars fill the card.
14581 var _rResizeTimer;
14582 window.addEventListener('resize',function(){
14583 clearTimeout(_rResizeTimer);
14584 _rResizeTimer=setTimeout(function(){
14585 var rcompBtn=document.querySelector('[data-rcomp].active');
14586 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14587 (function(){
14588 var scEl=document.getElementById('r-scatter-chart');
14589 if(!scEl||!SCAT_D||!SCAT_D.length)return;
14590 var H=224,PL=52,PB=36,PT=12,PR=14;
14591 var W=Math.max(320,scEl.offsetWidth||480);
14592 var cW=W-PL-PR,cH=H-PT-PB;
14593 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14594 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14595 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14596 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">';
14597 [0,0.25,0.5,0.75,1].forEach(function(t){var y=PT+cH*(1-t);s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';});
14598 [0,0.25,0.5,0.75,1].forEach(function(t){var x=PL+cW*t;s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';});
14599 SCAT_D.forEach(function(d,i){var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';});
14600 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>';
14601 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>';
14602 s+='</svg>';scEl.innerHTML=s;
14603 })();
14604 if(semSel)renderSemantic(semSel.value||'functions');
14605 if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14606 },120);
14607 });
14608 })();
14609
14610 (function randomizeWatermarks() {
14611 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14612 if (!wms.length) return;
14613 var placed = [];
14614 function tooClose(top, left) {
14615 for (var i = 0; i < placed.length; i++) {
14616 var dt = Math.abs(placed[i][0] - top);
14617 var dl = Math.abs(placed[i][1] - left);
14618 if (dt < 20 && dl < 18) return true;
14619 }
14620 return false;
14621 }
14622 function pick(leftBand) {
14623 for (var attempt = 0; attempt < 50; attempt++) {
14624 var top = Math.random() * 85 + 5;
14625 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14626 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14627 }
14628 var top = Math.random() * 85 + 5;
14629 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14630 placed.push([top, left]);
14631 return [top, left];
14632 }
14633 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14634 var half = Math.floor(wms.length / 2);
14635 wms.forEach(function (img, i) {
14636 var pos = pick(i < half);
14637 var size = Math.floor(Math.random() * 100 + 160);
14638 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14639 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14640 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;
14641 });
14642 })();
14643
14644 (function spawnCodeParticles() {
14645 var container = document.getElementById('code-particles');
14646 if (!container) return;
14647 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'];
14648 for (var i = 0; i < 38; i++) {
14649 (function(idx) {
14650 var el = document.createElement('span');
14651 el.className = 'code-particle';
14652 el.textContent = snippets[idx % snippets.length];
14653 var left = Math.random() * 94 + 2;
14654 var top = Math.random() * 88 + 6;
14655 var dur = (Math.random() * 10 + 9).toFixed(1);
14656 var delay = (Math.random() * 18).toFixed(1);
14657 var rot = (Math.random() * 26 - 13).toFixed(1);
14658 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14659 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';
14660 container.appendChild(el);
14661 })(i);
14662 }
14663 })();
14664
14665 {% if pdf_generating %}
14666 // Poll for PDF readiness and swap the disabled button to a live link once done.
14667 (function() {
14668 var openBtn = document.getElementById('pdf-open-btn');
14669 var dlBtn = document.getElementById('pdf-download-btn');
14670 function checkPdf() {
14671 fetch('/api/runs/{{ run_id }}/pdf-status')
14672 .then(function(r) { return r.json(); })
14673 .then(function(d) {
14674 if (d.ready) {
14675 if (openBtn) {
14676 var a = document.createElement('a');
14677 a.className = 'button';
14678 a.id = 'pdf-open-btn';
14679 a.href = '/runs/pdf/{{ run_id }}';
14680 a.target = '_blank';
14681 a.rel = 'noopener';
14682 a.textContent = 'Open PDF';
14683 openBtn.replaceWith(a);
14684 }
14685 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14686 } else {
14687 setTimeout(checkPdf, 3000);
14688 }
14689 })
14690 .catch(function() { setTimeout(checkPdf, 5000); });
14691 }
14692 setTimeout(checkPdf, 3000);
14693 })();
14694 {% endif %}
14695
14696 })();
14697 </script>
14698 <script nonce="{{ csp_nonce }}">
14699 (function(){
14700 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'}];
14701 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);});}
14702 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14703 function init(){
14704 var btn=document.getElementById('settings-btn');if(!btn)return;
14705 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14706 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>';
14707 document.body.appendChild(m);
14708 var g=document.getElementById('scheme-grid');
14709 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);});
14710 var cl=document.getElementById('settings-close');
14711 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);
14712 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');});
14713 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14714 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14715 }
14716 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14717 }());
14718 </script>
14719 <footer class="site-footer">
14720 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
14721 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14722 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14723 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14724 · <a href="/api-docs" rel="noopener">REST API</a>
14725 </footer>
14726 {% if confluence_configured %}
14727 <script nonce="{{ csp_nonce }}">
14728 (function() {
14729 var postBtn = document.getElementById('postConfluenceBtn');
14730 var copyBtn = document.getElementById('copyWikiBtn');
14731 var modal = document.getElementById('confluenceModal');
14732 if (!postBtn || !modal) return;
14733
14734 postBtn.addEventListener('click', function() {
14735 document.getElementById('confStatus').style.display = 'none';
14736 modal.style.display = 'flex';
14737 });
14738 document.getElementById('confCancelBtn').addEventListener('click', function() {
14739 modal.style.display = 'none';
14740 });
14741 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14742
14743 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14744 var btn = this;
14745 btn.disabled = true;
14746 var status = document.getElementById('confStatus');
14747 status.style.display = 'block';
14748 status.style.background = '#dbeafe';
14749 status.style.color = '#1e40af';
14750 status.textContent = 'Posting to Confluence…';
14751 var resp = await fetch('/api/confluence/post', {
14752 method: 'POST',
14753 headers: { 'Content-Type': 'application/json' },
14754 body: JSON.stringify({
14755 run_id: '{{ run_id }}',
14756 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14757 report_url: document.getElementById('confReportUrl').value.trim() || null
14758 })
14759 });
14760 var data = await resp.json();
14761 if (data.ok) {
14762 status.style.background = '#dcfce7'; status.style.color = '#166534';
14763 status.textContent = 'Posted! Page ID: ' + data.page_id;
14764 } else {
14765 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14766 status.textContent = 'Error: ' + (data.error || 'Unknown error');
14767 }
14768 btn.disabled = false;
14769 });
14770
14771 if (copyBtn) {
14772 copyBtn.addEventListener('click', async function() {
14773 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14774 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14775 var text = await resp.text();
14776 try {
14777 await navigator.clipboard.writeText(text);
14778 var orig = copyBtn.textContent;
14779 copyBtn.textContent = 'Copied!';
14780 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
14781 } catch(e) {
14782 alert('Clipboard write failed — check browser permissions.');
14783 }
14784 });
14785 }
14786 })();
14787 </script>
14788 {% endif %}
14789 {% if let Some(banner) = report_header_footer %}
14790 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
14791 {% endif %}
14792</body>
14793</html>
14794"##,
14795 ext = "html"
14796)]
14797#[allow(clippy::struct_excessive_bools)]
14799struct ResultTemplate {
14800 version: &'static str,
14801 report_title: String,
14802 project_path: String,
14803 output_dir: String,
14804 run_id: String,
14805 files_analyzed: u64,
14806 files_skipped: u64,
14807 physical_lines: u64,
14808 code_lines: u64,
14809 comment_lines: u64,
14810 blank_lines: u64,
14811 mixed_lines: u64,
14812 functions: u64,
14813 classes: u64,
14814 variables: u64,
14815 imports: u64,
14816 html_url: Option<String>,
14817 pdf_url: Option<String>,
14818 json_url: Option<String>,
14819 html_download_url: Option<String>,
14820 pdf_download_url: Option<String>,
14821 json_download_url: Option<String>,
14822 html_path: Option<String>,
14823 pdf_path: Option<String>,
14824 json_path: Option<String>,
14825 prev_run_id: Option<String>,
14826 prev_run_timestamp: Option<String>,
14827 prev_run_code_lines: Option<u64>,
14828 prev_fa_str: String,
14830 prev_fs_str: String,
14831 prev_pl_str: String,
14832 prev_cl_str: String,
14833 prev_cml_str: String,
14834 prev_bl_str: String,
14835 delta_fa_str: String,
14837 delta_fa_class: String,
14838 delta_fs_str: String,
14839 delta_fs_class: String,
14840 delta_pl_str: String,
14841 delta_pl_class: String,
14842 delta_cl_str: String,
14843 delta_cl_class: String,
14844 delta_cml_str: String,
14845 delta_cml_class: String,
14846 delta_bl_str: String,
14847 delta_bl_class: String,
14848 delta_lines_added: Option<i64>,
14850 delta_lines_removed: Option<i64>,
14851 delta_lines_net_str: String,
14852 delta_lines_net_class: String,
14853 delta_files_added: Option<usize>,
14854 delta_files_removed: Option<usize>,
14855 delta_files_modified: Option<usize>,
14856 delta_files_unchanged: Option<usize>,
14857 delta_unmodified_lines: Option<u64>,
14858 git_branch: Option<String>,
14860 git_commit: Option<String>,
14861 git_author: Option<String>,
14862 prev_scan_count: usize,
14864 current_scan_number: usize,
14865 submodule_rows: Vec<SubmoduleRow>,
14867 scan_config_url: String,
14868 lang_chart_json: String,
14869 #[allow(dead_code)]
14871 scatter_chart_json: String,
14872 #[allow(dead_code)]
14873 semantic_chart_json: String,
14874 #[allow(dead_code)]
14875 submodule_chart_json: String,
14876 #[allow(dead_code)]
14877 has_submodule_data: bool,
14878 #[allow(dead_code)]
14879 has_semantic_data: bool,
14880 pdf_generating: bool,
14881 csp_nonce: String,
14882 confluence_configured: bool,
14884 report_header_footer: Option<String>,
14886}
14887
14888#[derive(Template)]
14889#[template(
14890 source = r##"
14891<!doctype html>
14892<html lang="en">
14893<head>
14894 <meta charset="utf-8">
14895 <meta name="viewport" content="width=device-width, initial-scale=1">
14896 <title>OxideSLOC | Analyzing…</title>
14897 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14898 <style nonce="{{ csp_nonce }}">
14899 :root {
14900 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14901 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14902 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
14903 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14904 }
14905 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
14906 *{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);}
14907 .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);}
14908 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14909 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
14910 .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));}
14911 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14912 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14913 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14914 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14915 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14916 @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; } }
14917 .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;}
14918 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14919 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
14920 .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
14921 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
14922 .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;}
14923 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
14924 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
14925 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
14926 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
14927 .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;}
14928 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
14929 .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;}
14930 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
14931 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
14932 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
14933 .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;}
14934 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
14935 .hidden{display:none!important;}
14936 .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;}
14937 .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;}
14938 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
14939 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
14940 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
14941 .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);}
14942 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
14943 .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;}
14944 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
14945 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14946 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14947 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
14948 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14949 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
14950 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
14951 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14952 .site-footer a{color:var(--muted);}
14953 .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;}
14954 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
14955 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
14956 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
14957 </style>
14958</head>
14959<body>
14960 <div class="background-watermarks" aria-hidden="true">
14961 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14962 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14963 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14964 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14965 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14966 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14967 </div>
14968 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14969 <nav class="top-nav">
14970 <div class="top-nav-inner">
14971 <a href="/" class="brand">
14972 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
14973 <div class="brand-copy">
14974 <h1 class="brand-title">OxideSLOC</h1>
14975 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
14976 </div>
14977 </a>
14978 <div class="nav-right">
14979 <a class="nav-pill" href="/">Home</a>
14980 <div class="nav-dropdown">
14981 <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>
14982 <div class="nav-dropdown-menu">
14983 <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>
14984 </div>
14985 </div>
14986 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14987 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14988 <div class="nav-dropdown">
14989 <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>
14990 <div class="nav-dropdown-menu">
14991 <a href="/webhook-setup"><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>
14992 </div>
14993 </div>
14994 <div class="server-status-wrap">
14995 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
14996 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
14997 </div>
14998 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14999 <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>
15000 </button>
15001 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15002 <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>
15003 <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>
15004 </button>
15005 </div>
15006 </div>
15007 </nav>
15008 <div class="page-body">
15009 <div class="wait-panel">
15010 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15011 <h2 class="wait-title">Analyzing your project…</h2>
15012 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15013 <div class="path-block">{{ project_path }}</div>
15014 <div class="metrics-row">
15015 <div class="metric-card">
15016 <div class="metric-label">Elapsed</div>
15017 <div class="metric-value" id="elapsed">0s</div>
15018 </div>
15019 <div class="metric-card">
15020 <div class="metric-label">Phase</div>
15021 <div class="metric-value" id="phase">Starting</div>
15022 </div>
15023 </div>
15024 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15025 <div class="warn-slow hidden" id="warn-slow">
15026 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.
15027 </div>
15028 <div class="err-panel hidden" id="err-panel">
15029 <strong>Analysis failed</strong>
15030 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15031 </div>
15032 <div class="actions hidden" id="actions">
15033 <a href="/scan" class="btn-primary">Try Again</a>
15034 <a href="/view-reports" class="btn-outline">View Reports</a>
15035 </div>
15036 </div>
15037 </div>
15038 <script nonce="{{ csp_nonce }}">
15039 (function() {
15040 var WAIT_ID = {{ wait_id_json|safe }};
15041 var startTime = Date.now();
15042 var pollInterval = 1500;
15043 var retries = 0;
15044 var maxRetries = 5;
15045 var warnShown = false;
15046
15047 function elapsed() {
15048 return Math.floor((Date.now() - startTime) / 1000);
15049 }
15050
15051 function updateElapsed() {
15052 var s = elapsed();
15053 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15054 }
15055
15056 function setPhase(txt) {
15057 document.getElementById('phase').textContent = txt;
15058 }
15059
15060 var elapsedTimer = setInterval(updateElapsed, 1000);
15061
15062 function poll() {
15063 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15064 .then(function(r) {
15065 if (!r.ok) throw new Error('HTTP ' + r.status);
15066 return r.json();
15067 })
15068 .then(function(data) {
15069 retries = 0;
15070 if (data.state === 'complete') {
15071 clearInterval(elapsedTimer);
15072 setPhase('Done');
15073 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15074 } else if (data.state === 'failed') {
15075 clearInterval(elapsedTimer);
15076 setPhase('Failed');
15077 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15078 document.getElementById('err-panel').classList.remove('hidden');
15079 document.getElementById('actions').classList.remove('hidden');
15080 } else {
15081 // still running
15082 var s = elapsed();
15083 if (s > 90 && !warnShown) {
15084 warnShown = true;
15085 document.getElementById('warn-slow').classList.remove('hidden');
15086 }
15087 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15088 setTimeout(poll, pollInterval);
15089 }
15090 })
15091 .catch(function(err) {
15092 retries++;
15093 if (retries >= maxRetries) {
15094 clearInterval(elapsedTimer);
15095 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15096 document.getElementById('err-panel').classList.remove('hidden');
15097 document.getElementById('actions').classList.remove('hidden');
15098 } else {
15099 // exponential back-off capped at 8s
15100 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15101 }
15102 });
15103 }
15104
15105 setTimeout(poll, pollInterval);
15106 })();
15107 </script>
15108 <footer class="site-footer">
15109 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
15110 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15111 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15112 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15113 · <a href="/api-docs" rel="noopener">REST API</a>
15114 </footer>
15115 <script nonce="{{ csp_nonce }}">
15116 (function(){
15117 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15118 if(s==="dark")b.classList.add("dark-theme");
15119 var tt=document.getElementById("theme-toggle");
15120 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15121 })();
15122 (function spawnCodeParticles(){
15123 var c=document.getElementById('code-particles');if(!c)return;
15124 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'];
15125 for(var i=0;i<32;i++){(function(idx){
15126 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15127 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15128 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15129 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15130 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15131 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15132 c.appendChild(el);
15133 })(i);}
15134 })();
15135 (function randomizeWatermarks(){
15136 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15137 var placed=[];
15138 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;}
15139 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];}
15140 var half=Math.floor(wms.length/2);
15141 wms.forEach(function(img,i){
15142 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15143 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15144 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15145 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15146 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15147 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15148 });
15149 })();
15150 </script>
15151 <script nonce="{{ csp_nonce }}">
15152 (function(){
15153 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'}];
15154 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);});}
15155 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15156 function init(){
15157 var btn=document.getElementById('settings-btn');if(!btn)return;
15158 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15159 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>';
15160 document.body.appendChild(m);
15161 var g=document.getElementById('scheme-grid');
15162 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);});
15163 var cl=document.getElementById('settings-close');
15164 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);
15165 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');});
15166 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15167 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15168 }
15169 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15170 }());
15171 </script>
15172</body>
15173</html>
15174"##,
15175 ext = "html"
15176)]
15177struct ScanWaitTemplate {
15178 version: &'static str,
15179 wait_id_json: String,
15180 project_path: String,
15181 csp_nonce: String,
15182}
15183
15184#[derive(Template)]
15185#[template(
15186 source = r##"
15187<!doctype html>
15188<html lang="en">
15189<head>
15190 <meta charset="utf-8">
15191 <meta name="viewport" content="width=device-width, initial-scale=1">
15192 <title>OxideSLOC | Error</title>
15193 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15194 <style nonce="{{ csp_nonce }}">
15195 :root {
15196 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15197 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15198 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15199 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15200 }
15201 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15202 *{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);}
15203 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15204 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15205 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15206 .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);}
15207 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15208 .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));}
15209 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15210 .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;}
15211 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15212 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15213 @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; } }
15214 .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;}
15215 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15216 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15217 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15218 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15219 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15220 .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;}
15221 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15222 .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);}
15223 .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;}
15224 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15225 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15226 .settings-modal-body{padding:14px 16px 16px;}
15227 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15228 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15229 .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;}
15230 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15231 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15232 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15233 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15234 .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;}
15235 .tz-select:focus{border-color:var(--oxide);}
15236 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15237 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15238 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15239 .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;}
15240 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15241 .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);}
15242 .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;}
15243 .btn-secondary:hover{background:var(--line);}
15244 .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;}
15245 .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;}
15246 .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;}
15247 @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));}}
15248 .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;}
15249 </style>
15250</head>
15251<body>
15252 <div class="background-watermarks" aria-hidden="true">
15253 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15254 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15255 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15256 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15257 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15258 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15259 </div>
15260 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15261 <div class="top-nav">
15262 <div class="top-nav-inner">
15263 <a class="brand" href="/">
15264 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15265 <div class="brand-copy">
15266 <div class="brand-title">OxideSLOC</div>
15267 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15268 </div>
15269 </a>
15270 <div class="nav-right">
15271 <a class="nav-pill" href="/">Home</a>
15272 <div class="nav-dropdown">
15273 <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>
15274 <div class="nav-dropdown-menu">
15275 <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>
15276 </div>
15277 </div>
15278 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15279 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15280 <div class="nav-dropdown">
15281 <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>
15282 <div class="nav-dropdown-menu">
15283 <a href="/webhook-setup"><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>
15284 </div>
15285 </div>
15286 <div class="server-status-wrap">
15287 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15288 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15289 </div>
15290 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15291 <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>
15292 </button>
15293 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15294 <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>
15295 <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>
15296 </button>
15297 </div>
15298 </div>
15299 </div>
15300
15301 <div class="page">
15302 <div class="panel">
15303 <h1>Error</h1>
15304 <div class="error-box">{{ message }}</div>
15305 <div class="actions">
15306 <a class="btn-primary" href="/scan">Back to setup</a>
15307 {% if let Some(report_url) = last_report_url %}
15308 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15309 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15310 {% else %}
15311 <a class="btn-secondary" href="/view-reports">View Reports</a>
15312 {% endif %}
15313 </div>
15314 </div>
15315 </div>
15316 <script nonce="{{ csp_nonce }}">
15317 (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");});})();
15318 (function spawnCodeParticles() {
15319 var container = document.getElementById('code-particles');
15320 if (!container) return;
15321 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'];
15322 for (var i = 0; i < 38; i++) {
15323 (function(idx) {
15324 var el = document.createElement('span');
15325 el.className = 'code-particle';
15326 el.textContent = snippets[idx % snippets.length];
15327 var left = Math.random() * 94 + 2;
15328 var top = Math.random() * 88 + 6;
15329 var dur = (Math.random() * 10 + 9).toFixed(1);
15330 var delay = (Math.random() * 18).toFixed(1);
15331 var rot = (Math.random() * 26 - 13).toFixed(1);
15332 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15333 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';
15334 container.appendChild(el);
15335 })(i);
15336 }
15337 })();
15338 (function randomizeWatermarks() {
15339 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15340 var placed = [];
15341 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; }
15342 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]; }
15343 var half = Math.floor(wms.length/2);
15344 wms.forEach(function(img, i) {
15345 var pos = pick(i < half);
15346 var w = Math.floor(Math.random()*60+80);
15347 var rot = (Math.random()*40-20).toFixed(1);
15348 var op = (Math.random()*0.08+0.05).toFixed(2);
15349 var animDur = (Math.random()*6+5).toFixed(1);
15350 var animDelay = (Math.random()*10).toFixed(1);
15351 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';
15352 });
15353 })();
15354 </script>
15355 <script nonce="{{ csp_nonce }}">
15356 (function(){
15357 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'}];
15358 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);});}
15359 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15360 function init(){
15361 var btn=document.getElementById('settings-btn');if(!btn)return;
15362 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15363 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>';
15364 document.body.appendChild(m);
15365 var g=document.getElementById('scheme-grid');
15366 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);});
15367 var cl=document.getElementById('settings-close');
15368 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);
15369 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');});
15370 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15371 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15372 }
15373 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15374 }());
15375 </script>
15376</body>
15377</html>
15378"##,
15379 ext = "html"
15380)]
15381struct ErrorTemplate {
15382 message: String,
15383 last_report_url: Option<String>,
15385 last_report_label: Option<String>,
15387 csp_nonce: String,
15388}
15389
15390#[derive(Template)]
15393#[template(
15394 source = r##"
15395<!doctype html>
15396<html lang="en">
15397<head>
15398 <meta charset="utf-8">
15399 <meta name="viewport" content="width=device-width, initial-scale=1">
15400 <title>OxideSLOC | Locate Scan Files</title>
15401 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15402 <style nonce="{{ csp_nonce }}">
15403 :root {
15404 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15405 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15406 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15407 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15408 }
15409 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15410 *{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);}
15411 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15412 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15413 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15414 .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);}
15415 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15416 .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));}
15417 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15418 .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;}
15419 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15420 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15421 @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;}}
15422 .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;}
15423 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15424 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15425 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15426 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15427 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15428 .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;}
15429 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15430 .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);}
15431 .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;}
15432 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15433 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15434 .settings-modal-body{padding:14px 16px 16px;}
15435 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15436 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15437 .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;}
15438 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15439 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15440 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15441 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15442 .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;}
15443 .tz-select:focus{border-color:var(--oxide);}
15444 .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15445 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15446 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15447 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15448 .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;}
15449 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15450 .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;}
15451 .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;}
15452 .btn-secondary:hover{background:var(--line);}
15453 .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;}
15454 .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;}
15455 .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;}
15456 @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));}}
15457 .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;}
15458 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15459 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15460 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15461 .relocate-row{display:flex;gap:8px;align-items:stretch;}
15462 .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;}
15463 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15464 body.dark-theme .relocate-input{background:var(--surface-2);}
15465 </style>
15466</head>
15467<body>
15468 <div class="background-watermarks" aria-hidden="true">
15469 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15470 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15471 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15472 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15473 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15474 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15475 </div>
15476 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15477 <div class="top-nav">
15478 <div class="top-nav-inner">
15479 <a class="brand" href="/">
15480 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15481 <div class="brand-copy">
15482 <div class="brand-title">OxideSLOC</div>
15483 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15484 </div>
15485 </a>
15486 <div class="nav-right">
15487 <a class="nav-pill" href="/">Home</a>
15488 <div class="nav-dropdown">
15489 <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>
15490 <div class="nav-dropdown-menu">
15491 <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>
15492 </div>
15493 </div>
15494 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15495 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15496 <div class="nav-dropdown">
15497 <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>
15498 <div class="nav-dropdown-menu">
15499 <a href="/webhook-setup"><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>
15500 </div>
15501 </div>
15502 <div class="server-status-wrap">
15503 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15504 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15505 </div>
15506 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15507 <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>
15508 </button>
15509 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15510 <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>
15511 <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>
15512 </button>
15513 </div>
15514 </div>
15515 </div>
15516
15517 <div class="page">
15518 <div class="panel">
15519 <h1>Scan Files Moved</h1>
15520 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15521 <div class="error-box">{{ message }}</div>
15522 <div class="relocate-section">
15523 <h2>Locate Scan Output</h2>
15524 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15525 <form method="post" action="/relocate-scan">
15526 <input type="hidden" name="run_id" value="{{ run_id }}">
15527 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15528 <div class="relocate-row">
15529 <input type="text" id="relocate-folder" name="folder_path"
15530 value="{{ folder_hint }}"
15531 placeholder="Path to folder containing scan output..."
15532 class="relocate-input" autocomplete="off" spellcheck="false">
15533 {% if !server_mode %}
15534 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
15535 {% endif %}
15536 </div>
15537 <div style="margin-top:12px;">
15538 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15539 </div>
15540 </form>
15541 </div>
15542 <div class="actions">
15543 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15544 <a class="btn-secondary" href="/view-reports">View Reports</a>
15545 </div>
15546 </div>
15547 </div>
15548 <script nonce="{{ csp_nonce }}">
15549 (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");});})();
15550 (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);}})();
15551 (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';});})();
15552 </script>
15553 <script nonce="{{ csp_nonce }}">
15554 (function(){
15555 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'}];
15556 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);});}
15557 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15558 function init(){
15559 var btn=document.getElementById('settings-btn');if(!btn)return;
15560 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15561 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>';
15562 document.body.appendChild(m);
15563 var g=document.getElementById('scheme-grid');
15564 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);});
15565 var cl=document.getElementById('settings-close');
15566 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);
15567 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');});
15568 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15569 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15570 }
15571 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15572 }());
15573 (function(){
15574 var btn=document.getElementById('browse-relocate-btn');
15575 if(!btn)return;
15576 btn.addEventListener('click',function(){
15577 btn.disabled=true;btn.textContent='...';
15578 var inp=document.getElementById('relocate-folder');
15579 var hint=inp?inp.value:'';
15580 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
15581 .then(function(r){return r.json();})
15582 .then(function(d){
15583 btn.disabled=false;btn.textContent='Browse…';
15584 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15585 })
15586 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15587 });
15588 }());
15589 </script>
15590</body>
15591</html>
15592"##,
15593 ext = "html"
15594)]
15595struct RelocateScanTemplate {
15596 message: String,
15597 run_id: String,
15598 folder_hint: String,
15599 redirect_url: String,
15600 server_mode: bool,
15601 csp_nonce: String,
15602}
15603
15604#[derive(Template)]
15607#[template(
15608 source = r##"
15609<!doctype html>
15610<html lang="en">
15611<head>
15612 <meta charset="utf-8">
15613 <meta name="viewport" content="width=device-width, initial-scale=1">
15614 <title>OxideSLOC | View Reports</title>
15615 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15616 <style nonce="{{ csp_nonce }}">
15617 :root {
15618 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15619 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15620 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15621 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15622 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15623 }
15624 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; }
15625 *{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);}
15626 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15627 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15628 .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);}
15629 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15630 .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));}
15631 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15632 .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;}
15633 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15634 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15635 @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; } }
15636 .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;}
15637 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15638 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15639 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15640 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15641 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15642 .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;}
15643 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15644 .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);}
15645 .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;}
15646 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15647 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15648 .settings-modal-body{padding:14px 16px 16px;}
15649 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15650 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15651 .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;}
15652 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15653 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15654 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15655 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15656 .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;}
15657 .tz-select:focus{border-color:var(--oxide);}
15658 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15659 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15660 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15661 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15662 .panel-meta{font-size:13px;color:var(--muted);}
15663 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15664 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15665 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15666 .per-page-label{font-size:13px;color:var(--muted);}
15667 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;}
15668 .filter-input{min-width:180px;cursor:text;}
15669 .table-wrap{width:100%;overflow-x:auto;}
15670 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15671 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;}
15672 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15673 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15674 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15675 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15676 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15677 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15678 tr:last-child td{border-bottom:none;}
15679 tr:hover td{background:var(--surface-2);}
15680 .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);}
15681 .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);}
15682 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15683 .metric-num{font-weight:700;color:var(--text);}
15684 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15685 .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;}
15686 .btn:hover{background:var(--line);}
15687 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15688 .btn.primary:hover{opacity:.9;}
15689 .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;}
15690 .btn-back:hover{background:var(--line);}
15691 .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;}
15692 .export-btn:hover{background:var(--line);}
15693 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15694 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15695 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15696 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15697 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15698 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15699 .pagination-info{font-size:13px;color:var(--muted);}
15700 .pagination-btns{display:flex;gap:6px;}
15701 .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;}
15702 .pg-btn:hover:not(:disabled){background:var(--line);}
15703 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15704 .pg-btn:disabled{opacity:.35;cursor:default;}
15705 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15706 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15707 .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;}
15708 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15709 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15710 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15711 .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);}
15712 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15713 .stat-chip:hover .stat-chip-tip{opacity:1;}
15714 .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;}
15715 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15716 .site-footer a{color:var(--muted);}
15717 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15718 .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%;}
15719 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15720 .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;}
15721 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15722 .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;}
15723 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15724 .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;}
15725 .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;}
15726 .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;}
15727 @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));}}
15728 .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;}
15729 .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;}
15730 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15731 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15732 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15733 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15734 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15735 .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;}
15736 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15737 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15738 .watched-chip-rm:hover{color:var(--oxide);}
15739 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15740 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15741 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
15742 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15743 .rpt-btn{min-width:58px;justify-content:center;}
15744 .flex-row{display:flex;align-items:center;gap:8px;}
15745 .report-cell{overflow:visible;white-space:normal;}
15746 #history-table col:nth-child(1){width:185px;}
15747 #history-table col:nth-child(2){width:220px;}
15748 #history-table col:nth-child(3){width:100px;}
15749 #history-table col:nth-child(4){width:72px;}
15750 #history-table col:nth-child(5){width:82px;}
15751 #history-table col:nth-child(6){width:82px;}
15752 #history-table col:nth-child(7){width:65px;}
15753 #history-table col:nth-child(8){width:90px;}
15754 #history-table col:nth-child(9){width:85px;}
15755 #history-table col:nth-child(10){width:115px;}
15756 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15757 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15758 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15759 .submod-details summary::-webkit-details-marker{display:none;}
15760.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15761 .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;}
15762 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15763 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15764 </style>
15765</head>
15766<body>
15767 <div class="background-watermarks" aria-hidden="true">
15768 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15769 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15770 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15771 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15772 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15773 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15774 </div>
15775 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15776 <div class="top-nav">
15777 <div class="top-nav-inner">
15778 <a class="brand" href="/">
15779 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15780 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
15781 </a>
15782 <div class="nav-right">
15783 <a class="nav-pill" href="/">Home</a>
15784 <div class="nav-dropdown">
15785 <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>
15786 <div class="nav-dropdown-menu">
15787 <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>
15788 </div>
15789 </div>
15790 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15791 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15792 <div class="nav-dropdown">
15793 <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>
15794 <div class="nav-dropdown-menu">
15795 <a href="/webhook-setup"><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>
15796 </div>
15797 </div>
15798 <div class="server-status-wrap">
15799 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15800 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15801 </div>
15802 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15803 <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>
15804 </button>
15805 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15806 <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>
15807 <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>
15808 </button>
15809 </div>
15810 </div>
15811 </div>
15812
15813 <div class="page">
15814 {% if let Some(err) = browse_error %}
15815 <div class="toast-error">
15816 <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>
15817 {{ err }}
15818 </div>
15819 {% endif %}
15820 {% if linked_count > 0 %}
15821 <div class="toast-success">
15822 <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>
15823 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
15824 </div>
15825 {% endif %}
15826 <div class="watched-bar">
15827 <div class="watched-bar-left">
15828 <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>
15829 <span class="watched-label">Watched Folders</span>
15830 <div class="watched-chips">
15831 {% for dir in watched_dirs %}
15832 <span class="watched-chip">
15833 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
15834 <form method="POST" action="/watched-dirs/remove" style="display:contents">
15835 <input type="hidden" name="folder_path" value="{{ dir }}">
15836 <input type="hidden" name="redirect_to" value="/view-reports">
15837 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
15838 </form>
15839 </span>
15840 {% endfor %}
15841 {% if watched_dirs.is_empty() %}
15842 <span class="watched-none">No folders watched — click Choose to add one</span>
15843 {% endif %}
15844 </div>
15845 </div>
15846 <div class="watched-bar-right">
15847 <button type="button" class="btn" id="add-watched-btn">
15848 <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>
15849 Choose
15850 </button>
15851 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
15852 <input type="hidden" name="redirect_to" value="/view-reports">
15853 <button type="submit" class="btn">↻ Refresh</button>
15854 </form>
15855 </div>
15856 </div>
15857 {% if total_scans > 0 %}
15858 <div class="summary-strip">
15859 <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>
15860 <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>
15861 <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>
15862 <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
15863 </div>
15864 {% endif %}
15865
15866 <section class="panel">
15867 <div class="panel-header">
15868 <div>
15869 <h1>View Reports</h1>
15870 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
15871 </div>
15872 <div class="flex-row">
15873 <button type="button" class="export-btn" id="export-csv-btn">
15874 <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>
15875 Export CSV
15876 </button>
15877 <button type="button" class="export-btn" id="export-xls-btn">
15878 <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>
15879 Export Excel
15880 </button>
15881 </div>
15882 </div>
15883
15884 {% if entries.is_empty() %}
15885 <div class="empty-state">
15886 <strong>No reports with viewable HTML yet</strong>
15887 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.
15888 </div>
15889 {% else %}
15890 <div class="filter-row">
15891 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
15892 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
15893 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
15894 </div>
15895 <div class="table-wrap">
15896 <table id="history-table">
15897 <colgroup>
15898 <col><col><col><col><col><col><col><col><col><col>
15899 </colgroup>
15900 <thead>
15901 <tr id="history-thead">
15902 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15903 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15904 <th>Run ID<div class="col-resize-handle"></div></th>
15905 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15906 <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>
15907 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15908 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15909 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15910 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15911 <th>Report<div class="col-resize-handle"></div></th>
15912 </tr>
15913 </thead>
15914 <tbody id="history-tbody">
15915 {% for entry in entries %}
15916 <tr class="history-row" data-run="{{ entry.run_id }}"
15917 data-timestamp="{{ entry.timestamp }}"
15918 data-project="{{ entry.project_label }}"
15919 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
15920 data-skipped="{{ entry.files_skipped }}"
15921 data-comments="{{ entry.comment_lines }}"
15922 data-blank="{{ entry.blank_lines }}"
15923 data-branch="{{ entry.git_branch }}"
15924 data-commit="{{ entry.git_commit }}"
15925 data-html-url="/runs/html/{{ entry.run_id }}">
15926 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
15927 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
15928 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
15929 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
15930 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
15931 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
15932 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
15933 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
15934 <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>
15935 <td class="report-cell">
15936 <div class="actions-cell">
15937 {% 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 %}
15938 {% 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 %}
15939 </div>
15940 {% if !entry.submodule_links.is_empty() %}
15941 <details class="submod-details">
15942 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
15943 <div class="submod-link-list">
15944 {% for sub in entry.submodule_links %}
15945 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
15946 {% endfor %}
15947 </div>
15948 </details>
15949 {% endif %}
15950 </td>
15951 </tr>
15952 {% endfor %}
15953 </tbody>
15954 </table>
15955 </div>
15956 <div class="pagination">
15957 <span class="pagination-info" id="pagination-info"></span>
15958 <div class="pagination-btns" id="pagination-btns"></div>
15959 <div class="flex-row">
15960 <span class="per-page-label">Show</span>
15961 <select class="per-page" id="per-page-sel">
15962 <option value="10">10 per page</option>
15963 <option value="25" selected>25 per page</option>
15964 <option value="50">50 per page</option>
15965 <option value="100">100 per page</option>
15966 </select>
15967 <span class="per-page-label" id="page-range-label"></span>
15968 </div>
15969 </div>
15970 {% endif %}
15971 </section>
15972 </div>
15973
15974 <footer class="site-footer">
15975 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
15976 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15977 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15978 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15979 · <a href="/api-docs" rel="noopener">REST API</a>
15980 </footer>
15981
15982 <script nonce="{{ csp_nonce }}">
15983 (function () {
15984 // ── Theme ──────────────────────────────────────────────────────────────
15985 var storageKey = 'oxide-sloc-theme';
15986 var body = document.body;
15987 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15988 var toggle = document.getElementById('theme-toggle');
15989 if (toggle) toggle.addEventListener('click', function () {
15990 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15991 body.classList.toggle('dark-theme', next === 'dark');
15992 try { localStorage.setItem(storageKey, next); } catch(e) {}
15993 });
15994
15995 // ── State ─────────────────────────────────────────────────────────────
15996 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
15997 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
15998 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
15999
16000 // Aggregate stats from first (most recent) row
16001 if (allRows.length) {
16002 var first = allRows[0];
16003 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();}
16004 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>':'');}
16005 setChipVal('agg-code', first.dataset.code);
16006 setChipVal('agg-files', first.dataset.files);
16007 setChipVal('agg-skipped', first.dataset.skipped);
16008 }
16009
16010 // ── Branch filter population ──────────────────────────────────────────
16011 (function() {
16012 var branches = {};
16013 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16014 var sel = document.getElementById('branch-filter');
16015 if (sel) Object.keys(branches).sort().forEach(function(b) {
16016 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16017 });
16018 })();
16019
16020 // ── Filter ────────────────────────────────────────────────────────────
16021 function getFilteredRows() {
16022 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16023 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16024 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16025 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16026 if (branch && (r.dataset.branch || '') !== branch) return false;
16027 return true;
16028 });
16029 }
16030
16031 // ── Pagination ────────────────────────────────────────────────────────
16032 function renderPage() {
16033 var filtered = getFilteredRows();
16034 var total = filtered.length;
16035 var totalPages = Math.max(1, Math.ceil(total / perPage));
16036 currentPage = Math.min(currentPage, totalPages);
16037 var start = (currentPage - 1) * perPage;
16038 var end = Math.min(start + perPage, total);
16039 var shown = {};
16040 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16041 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16042 r.style.display = shown[r.dataset.run] ? '' : 'none';
16043 });
16044 var rl = document.getElementById('page-range-label');
16045 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16046 var info = document.getElementById('pagination-info');
16047 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16048 var btns = document.getElementById('pagination-btns');
16049 if (!btns) return;
16050 btns.innerHTML = '';
16051 function makeBtn(lbl, pg, active, disabled) {
16052 var b = document.createElement('button');
16053 b.className = 'pg-btn' + (active ? ' active' : '');
16054 b.textContent = lbl; b.disabled = disabled;
16055 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16056 return b;
16057 }
16058 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16059 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16060 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16061 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16062 }
16063
16064 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16065 window.applyFilters = function() { currentPage = 1; renderPage(); };
16066
16067 // ── Sorting ───────────────────────────────────────────────────────────
16068 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16069 function doSort(col, type, order) {
16070 var tbody = document.getElementById('history-tbody');
16071 if (!tbody) return;
16072 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16073 rows.sort(function(a, b) {
16074 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16075 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16076 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16077 return va < vb ? 1 : va > vb ? -1 : 0;
16078 });
16079 rows.forEach(function(r) { tbody.appendChild(r); });
16080 currentPage = 1; renderPage();
16081 }
16082 sortHeaders.forEach(function(th) {
16083 th.addEventListener('click', function(e) {
16084 if (e.target.classList.contains('col-resize-handle')) return;
16085 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16086 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16087 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16088 th.classList.add('sort-' + sortOrder);
16089 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16090 doSort(col, type, sortOrder);
16091 });
16092 });
16093
16094 // ── Column resize ─────────────────────────────────────────────────────
16095 (function() {
16096 var table = document.getElementById('history-table');
16097 if (!table) return;
16098 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16099 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16100 ths.forEach(function(th, i) {
16101 var handle = th.querySelector('.col-resize-handle');
16102 if (!handle || !cols[i]) return;
16103 var startX, startW;
16104 handle.addEventListener('mousedown', function(e) {
16105 e.stopPropagation(); e.preventDefault();
16106 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16107 handle.classList.add('dragging');
16108 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16109 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16110 document.addEventListener('mousemove', onMove);
16111 document.addEventListener('mouseup', onUp);
16112 });
16113 });
16114 })();
16115
16116 // ── Reset view ────────────────────────────────────────────────────────
16117 window.resetView = function() {
16118 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16119 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16120 sortCol = null; sortOrder = 'asc';
16121 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16122 var tbody = document.getElementById('history-tbody');
16123 if (tbody) {
16124 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16125 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16126 rows.forEach(function(r) { tbody.appendChild(r); });
16127 }
16128 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16129 var table = document.getElementById('history-table');
16130 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16131 currentPage = 1; renderPage();
16132 };
16133
16134 renderPage();
16135
16136 // ── Export helpers ────────────────────────────────────────────────────
16137 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
16138 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16139 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);}
16140 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;');}
16141 function slocXlsx(fname,sheet,hdrs,rows){
16142 var enc=new TextEncoder();
16143 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;}
16144 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;}
16145 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16146 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16147 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16148 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;}
16149 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];}
16150 var rx='<row r="1">';
16151 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16152 rx+='</row>';
16153 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>';});
16154 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16155 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>';
16156 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>';
16157 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>';
16158 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>',
16159 '_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>',
16160 '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>',
16161 '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>',
16162 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16163 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'];
16164 var zparts=[],zcds=[],zoff=0,znf=0;
16165 order.forEach(function(name){
16166 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16167 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]);
16168 var entry=new Uint8Array(lha.length+nb.length+sz);
16169 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16170 zparts.push(entry);
16171 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));
16172 var cde=new Uint8Array(cda.length+nb.length);
16173 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16174 zcds.push(cde);zoff+=entry.length;znf++;
16175 });
16176 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16177 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]);
16178 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16179 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16180 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16181 zout.set(new Uint8Array(ea),zpos);
16182 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16183 }
16184
16185 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16186 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;}
16187 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16188 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16189
16190 var csvBtn = document.getElementById('export-csv-btn');
16191 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16192 var xlsBtn = document.getElementById('export-xls-btn');
16193 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16194
16195 // ── Remaining CSP-safe event bindings ────────────────────────────────
16196 (function wireEvents() {
16197 var el;
16198 el = document.getElementById('reset-view-btn');
16199 if (el) el.addEventListener('click', window.resetView);
16200 el = document.getElementById('project-filter');
16201 if (el) el.addEventListener('input', window.applyFilters);
16202 el = document.getElementById('branch-filter');
16203 if (el) el.addEventListener('change', window.applyFilters);
16204 el = document.getElementById('per-page-sel');
16205 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16206 el = document.getElementById('add-watched-btn');
16207 if (el) el.addEventListener('click', function() {
16208 fetch('/pick-directory?kind=reports')
16209 .then(function(r) { return r.json(); })
16210 .then(function(data) {
16211 if (!data.cancelled && data.selected_path) {
16212 var form = document.createElement('form');
16213 form.method = 'POST';
16214 form.action = '/watched-dirs/add';
16215 var ri = document.createElement('input');
16216 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16217 var fi = document.createElement('input');
16218 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16219 form.appendChild(ri); form.appendChild(fi);
16220 document.body.appendChild(form);
16221 form.submit();
16222 }
16223 })
16224 .catch(function(e) { alert('Could not open folder picker: ' + e); });
16225 });
16226 })();
16227
16228 (function randomizeWatermarks() {
16229 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16230 if (!wms.length) return;
16231 var placed = [];
16232 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;}
16233 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];}
16234 var half=Math.floor(wms.length/2);
16235 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;});
16236 })();
16237
16238 (function spawnCodeParticles() {
16239 var container = document.getElementById('code-particles');
16240 if (!container) return;
16241 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'];
16242 for (var i = 0; i < 38; i++) {
16243 (function(idx) {
16244 var el = document.createElement('span');
16245 el.className = 'code-particle';
16246 el.textContent = snippets[idx % snippets.length];
16247 var left = Math.random() * 94 + 2;
16248 var top = Math.random() * 88 + 6;
16249 var dur = (Math.random() * 10 + 9).toFixed(1);
16250 var delay = (Math.random() * 18).toFixed(1);
16251 var rot = (Math.random() * 26 - 13).toFixed(1);
16252 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16253 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';
16254 container.appendChild(el);
16255 })(i);
16256 }
16257 })();
16258 })();
16259 </script>
16260 <script nonce="{{ csp_nonce }}">
16261 (function(){
16262 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'}];
16263 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);});}
16264 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16265 function init(){
16266 var btn=document.getElementById('settings-btn');if(!btn)return;
16267 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16268 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>';
16269 document.body.appendChild(m);
16270 var g=document.getElementById('scheme-grid');
16271 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);});
16272 var cl=document.getElementById('settings-close');
16273 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);
16274 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');});
16275 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16276 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16277 }
16278 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16279 }());
16280 </script>
16281</body>
16282</html>
16283"##,
16284 ext = "html"
16285)]
16286struct HistoryTemplate {
16287 version: &'static str,
16288 entries: Vec<HistoryEntryRow>,
16289 total_scans: usize,
16290 linked_count: usize,
16291 browse_error: Option<String>,
16292 watched_dirs: Vec<String>,
16293 csp_nonce: String,
16294}
16295
16296#[derive(Template)]
16299#[template(
16300 source = r##"
16301<!doctype html>
16302<html lang="en">
16303<head>
16304 <meta charset="utf-8">
16305 <meta name="viewport" content="width=device-width, initial-scale=1">
16306 <title>OxideSLOC | Compare Scans</title>
16307 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16308 <style nonce="{{ csp_nonce }}">
16309 :root {
16310 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16311 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16312 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16313 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16314 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16315 }
16316 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16317 *{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);}
16318 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16319 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16320 .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);}
16321 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16322 .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));}
16323 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16324 .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;}
16325 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16326 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16327 @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; } }
16328 .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;}
16329 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16330 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16331 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16332 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16333 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16334 .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;}
16335 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16336 .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);}
16337 .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;}
16338 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16339 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16340 .settings-modal-body{padding:14px 16px 16px;}
16341 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16342 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16343 .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;}
16344 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16345 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16346 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16347 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16348 .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;}
16349 .tz-select:focus{border-color:var(--oxide);}
16350 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16351 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16352 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16353 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16354 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16355 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16356 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16357 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16358 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16359 .per-page-label{font-size:13px;color:var(--muted);}
16360 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;}
16361 .filter-input{min-width:180px;cursor:text;}
16362 .table-wrap{width:100%;overflow-x:auto;}
16363 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16364 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;}
16365 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16366 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16367 #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;}
16368 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16369 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16370 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16371 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16372 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16373 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16374 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16375 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16376 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16377 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16378 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16379 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16380 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16381 tr:last-child td{border-bottom:none;}
16382 tr.selected td{background:var(--sel-bg);}
16383 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16384 tr:hover:not(.selected) td{background:var(--surface-2);}
16385 tr{cursor:pointer;}
16386 .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);}
16387 .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);}
16388 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16389 .metric-num{font-weight:700;color:var(--text);}
16390 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16391 .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;}
16392 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16393 .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;}
16394 .btn:hover{background:var(--line);}
16395 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16396 .btn.primary:hover{opacity:.9;}
16397 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16398 .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;}
16399 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16400 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16401 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16402 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16403 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16404 .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;}
16405 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16406 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16407 .watched-chip-rm:hover{color:var(--oxide);}
16408 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16409 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16410 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
16411 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16412 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16413 .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;}
16414 .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;}
16415 .btn-back:hover{background:var(--line);}
16416 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16417 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16418 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16419 .pagination-info{font-size:13px;color:var(--muted);}
16420 .pagination-btns{display:flex;gap:6px;}
16421 .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;}
16422 .pg-btn:hover:not(:disabled){background:var(--line);}
16423 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16424 .pg-btn:disabled{opacity:.35;cursor:default;}
16425 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16426 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16427 .site-footer a{color:var(--muted);}
16428 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16429 .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;}
16430 .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;}
16431 .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;}
16432 @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));}}
16433 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16434 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16435 .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;}
16436 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16437 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16438 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16439 .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);}
16440 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16441 .stat-chip:hover .stat-chip-tip{opacity:1;}
16442 .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;}
16443 .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;}
16444 .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%;}
16445 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16446 .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;}
16447 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16448 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16449 .hidden{display:none!important;}
16450 .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%;}
16451 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16452 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16453 .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;}
16454 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16455 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16456 .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;}
16457 .scope-option:hover{background:var(--line);}
16458 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16459 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16460 .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;}
16461 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16462 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16463 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16464 .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;}
16465 </style>
16466</head>
16467<body>
16468 <div class="background-watermarks" aria-hidden="true">
16469 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16470 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16471 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16472 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16473 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16474 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16475 </div>
16476 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16477 <div class="top-nav">
16478 <div class="top-nav-inner">
16479 <a class="brand" href="/">
16480 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16481 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16482 </a>
16483 <div class="nav-right">
16484 <a class="nav-pill" href="/">Home</a>
16485 <div class="nav-dropdown">
16486 <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>
16487 <div class="nav-dropdown-menu">
16488 <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>
16489 </div>
16490 </div>
16491 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16492 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16493 <div class="nav-dropdown">
16494 <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>
16495 <div class="nav-dropdown-menu">
16496 <a href="/webhook-setup"><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>
16497 </div>
16498 </div>
16499 <div class="server-status-wrap">
16500 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16501 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
16502 </div>
16503 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16504 <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>
16505 </button>
16506 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16507 <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>
16508 <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>
16509 </button>
16510 </div>
16511 </div>
16512 </div>
16513
16514 <div class="page">
16515 <div class="watched-bar">
16516 <div class="watched-bar-left">
16517 <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>
16518 <span class="watched-label">Watched Folders</span>
16519 <div class="watched-chips">
16520 {% for dir in watched_dirs %}
16521 <span class="watched-chip">
16522 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16523 <form method="POST" action="/watched-dirs/remove" style="display:contents">
16524 <input type="hidden" name="folder_path" value="{{ dir }}">
16525 <input type="hidden" name="redirect_to" value="/compare-scans">
16526 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
16527 </form>
16528 </span>
16529 {% endfor %}
16530 {% if watched_dirs.is_empty() %}
16531 <span class="watched-none">No folders watched — click Choose to add one</span>
16532 {% endif %}
16533 </div>
16534 </div>
16535 <div class="watched-bar-right">
16536 <button type="button" class="btn" id="add-watched-btn">
16537 <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>
16538 Choose
16539 </button>
16540 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16541 <input type="hidden" name="redirect_to" value="/compare-scans">
16542 <button type="submit" class="btn">↻ Refresh</button>
16543 </form>
16544 </div>
16545 </div>
16546 {% if total_scans > 0 %}
16547 <div class="summary-strip">
16548 <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>
16549 <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>
16550 <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>
16551 <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>
16552 </div>
16553 {% endif %}
16554 <section class="panel">
16555 <div class="panel-header">
16556 <div>
16557 <h1>Compare Scans</h1>
16558 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16559 </div>
16560 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16561 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16562 <button class="btn primary" id="compare-btn" disabled>
16563 <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>
16564 Compare <span class="sel-count" id="sel-count">0/2</span>
16565 </button>
16566 </div>
16567 </div>
16568 </div>
16569
16570 {% if entries.is_empty() %}
16571 <div class="empty-state">
16572 <strong>No scans yet</strong>
16573 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.
16574 </div>
16575 {% else %}
16576 <div class="filter-row">
16577 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16578 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16579 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
16580 </div>
16581 <div class="scope-panel hidden" id="scope-panel">
16582 <div class="scope-panel-label">
16583 <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>
16584 Compare scope — choose what to include
16585 </div>
16586 <div class="scope-options" id="scope-options"></div>
16587 </div>
16588 {% if total_scans > 0 %}
16589 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16590 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16591 <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>
16592 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16593 </div>
16594 </div>
16595 {% endif %}
16596 <div class="table-wrap">
16597 <table id="compare-table">
16598 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16599 <thead>
16600 <tr id="compare-thead">
16601 <th><div class="col-resize-handle"></div></th>
16602 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16603 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16604 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16605 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16606 <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>
16607 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16608 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16609 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16610 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16611 <th>Submodules<div class="col-resize-handle"></div></th>
16612 </tr>
16613 </thead>
16614 <tbody id="compare-tbody">
16615 {% for entry in entries %}
16616 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16617 data-timestamp="{{ entry.timestamp }}"
16618 data-project="{{ entry.project_label }}"
16619 data-files="{{ entry.files_analyzed }}"
16620 data-code="{{ entry.code_lines }}"
16621 data-comments="{{ entry.comment_lines }}"
16622 data-blank="{{ entry.blank_lines }}"
16623 data-branch="{{ entry.git_branch }}"
16624 data-commit="{{ entry.git_commit }}"
16625 data-submodules="{{ entry.submodule_names_csv }}">
16626 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16627 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16628 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16629 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16630 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16631 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16632 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16633 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16634 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16635 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16636 <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>
16637 </tr>
16638 {% endfor %}
16639 </tbody>
16640 </table>
16641 </div>
16642 <div class="pagination">
16643 <span class="pagination-info" id="pagination-info"></span>
16644 <div class="pagination-btns" id="pagination-btns"></div>
16645 <div class="flex-row">
16646 <span class="per-page-label">Show</span>
16647 <select class="per-page" id="per-page-sel">
16648 <option value="10">10 per page</option>
16649 <option value="25" selected>25 per page</option>
16650 <option value="50">50 per page</option>
16651 <option value="100">100 per page</option>
16652 </select>
16653 <span class="per-page-label" id="page-range-label"></span>
16654 </div>
16655 </div>
16656 {% endif %}
16657 </section>
16658 </div>
16659
16660 <footer class="site-footer">
16661 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
16662 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16663 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16664 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16665 · <a href="/api-docs" rel="noopener">REST API</a>
16666 </footer>
16667
16668 <script nonce="{{ csp_nonce }}">
16669 (function () {
16670 // ── Theme ──────────────────────────────────────────────────────────────
16671 var storageKey = 'oxide-sloc-theme';
16672 var body = document.body;
16673 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16674 var toggle = document.getElementById('theme-toggle');
16675 if (toggle) toggle.addEventListener('click', function () {
16676 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16677 body.classList.toggle('dark-theme', next === 'dark');
16678 try { localStorage.setItem(storageKey, next); } catch(e) {}
16679 });
16680
16681 // ── State ─────────────────────────────────────────────────────────────
16682 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16683 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16684 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16685
16686 // ── Stat chips ────────────────────────────────────────────────────────
16687 (function() {
16688 var projects = {}, latestTs = '', latestRow = null;
16689 allRows.forEach(function(r) {
16690 var p = r.dataset.project || ''; if (p) projects[p] = true;
16691 var ts = r.dataset.timestamp || '';
16692 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16693 });
16694 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();}
16695 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>':'');}
16696 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16697 if (latestRow) {
16698 setChipVal('agg-code', latestRow.dataset.code);
16699 setChipVal('agg-files', latestRow.dataset.files);
16700 }
16701 })();
16702
16703 // ── Branch filter population ──────────────────────────────────────────
16704 (function() {
16705 var branches = {};
16706 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16707 var sel = document.getElementById('branch-filter');
16708 if (sel) Object.keys(branches).sort().forEach(function(b) {
16709 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16710 });
16711 })();
16712
16713 // ── Filter ────────────────────────────────────────────────────────────
16714 function getFilteredRows() {
16715 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16716 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16717 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16718 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16719 if (branch && (r.dataset.branch || '') !== branch) return false;
16720 return true;
16721 });
16722 }
16723
16724 // ── Pagination ────────────────────────────────────────────────────────
16725 function renderPage() {
16726 var filtered = getFilteredRows();
16727 var total = filtered.length;
16728 var totalPages = Math.max(1, Math.ceil(total / perPage));
16729 currentPage = Math.min(currentPage, totalPages);
16730 var start = (currentPage - 1) * perPage;
16731 var end = Math.min(start + perPage, total);
16732 var shown = {};
16733 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16734 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16735 r.style.display = shown[r.dataset.run] ? '' : 'none';
16736 });
16737 var rl = document.getElementById('page-range-label');
16738 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16739 var info = document.getElementById('pagination-info');
16740 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16741 var btns = document.getElementById('pagination-btns');
16742 if (!btns) return;
16743 btns.innerHTML = '';
16744 function makeBtn(lbl, pg, active, disabled) {
16745 var b = document.createElement('button');
16746 b.className = 'pg-btn' + (active ? ' active' : '');
16747 b.textContent = lbl; b.disabled = disabled;
16748 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16749 return b;
16750 }
16751 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16752 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16753 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16754 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16755 }
16756
16757 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16758 window.applyFilters = function() { currentPage = 1; renderPage(); };
16759
16760 // ── Sorting ───────────────────────────────────────────────────────────
16761 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16762 function doSort(col, type, order) {
16763 var tbody = document.getElementById('compare-tbody');
16764 if (!tbody) return;
16765 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16766 rows.sort(function(a, b) {
16767 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16768 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16769 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16770 return va < vb ? 1 : va > vb ? -1 : 0;
16771 });
16772 rows.forEach(function(r) { tbody.appendChild(r); });
16773 currentPage = 1; renderPage();
16774 }
16775 sortHeaders.forEach(function(th) {
16776 th.addEventListener('click', function(e) {
16777 if (e.target.classList.contains('col-resize-handle')) return;
16778 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16779 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16780 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16781 th.classList.add('sort-' + sortOrder);
16782 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16783 doSort(col, type, sortOrder);
16784 });
16785 });
16786
16787 // Apply default sort (timestamp desc) on initial load
16788 (function() {
16789 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
16790 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
16791 })();
16792
16793 // ── Column resize ─────────────────────────────────────────────────────
16794 (function() {
16795 var table = document.getElementById('compare-table');
16796 if (!table) return;
16797 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16798 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
16799 ths.forEach(function(th, i) {
16800 var handle = th.querySelector('.col-resize-handle');
16801 if (!handle || !cols[i]) return;
16802 var startX, startW;
16803 handle.addEventListener('mousedown', function(e) {
16804 e.stopPropagation(); e.preventDefault();
16805 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16806 handle.classList.add('dragging');
16807 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16808 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16809 document.addEventListener('mousemove', onMove);
16810 document.addEventListener('mouseup', onUp);
16811 });
16812 });
16813 })();
16814
16815 // ── Reset view ────────────────────────────────────────────────────────
16816 window.resetView = function() {
16817 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16818 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16819 sortCol = null; sortOrder = 'asc';
16820 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16821 var tbody = document.getElementById('compare-tbody');
16822 if (tbody) {
16823 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16824 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16825 rows.forEach(function(r) { tbody.appendChild(r); });
16826 }
16827 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16828 var table = document.getElementById('compare-table');
16829 currentPage = 1; renderPage();
16830 currentPage = 1; renderPage();
16831 };
16832
16833 renderPage();
16834
16835 // ── Row selection state ───────────────────────────────────────────────
16836 var selected = [];
16837 function updateCompareBtn() {
16838 var btn = document.getElementById('compare-btn');
16839 var cnt = document.getElementById('sel-count');
16840 if (!btn) return;
16841 btn.disabled = selected.length !== 2;
16842 if (cnt) cnt.textContent = selected.length + '/2';
16843 }
16844
16845 function toggleRow(row) {
16846 var vid = row.dataset.vid || row.dataset.run;
16847 var idx = selected.indexOf(vid);
16848 if (idx >= 0) {
16849 selected.splice(idx, 1);
16850 row.classList.remove('selected');
16851 var b = document.getElementById('badge-' + vid);
16852 if (b) b.textContent = '';
16853 } else {
16854 if (selected.length >= 2) return;
16855 selected.push(vid);
16856 row.classList.add('selected');
16857 }
16858 selected.forEach(function(v, i) {
16859 var b = document.getElementById('badge-' + v);
16860 if (b) b.textContent = i + 1;
16861 });
16862 updateCompareBtn();
16863 buildScopePanel();
16864 }
16865
16866 // ── Scope panel ───────────────────────────────────────────────────────
16867 var selectedScope = 'all';
16868
16869 function buildScopePanel() {
16870 var panel = document.getElementById('scope-panel');
16871 var opts = document.getElementById('scope-options');
16872 if (!panel || !opts) return;
16873 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16874
16875 // Collect union of submodules from both selected rows.
16876 var allSubs = {};
16877 selected.forEach(function(vid) {
16878 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
16879 if (!row) return;
16880 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
16881 });
16882 var subList = Object.keys(allSubs).sort();
16883 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16884
16885 panel.classList.remove('hidden');
16886 opts.innerHTML = '';
16887
16888 function makeOption(value, label, title) {
16889 var div = document.createElement('div');
16890 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
16891 div.dataset.scopeValue = value;
16892 if (title) div.title = title;
16893 var radio = document.createElement('span');
16894 radio.className = 'scope-option-radio';
16895 var lbl = document.createElement('span');
16896 lbl.textContent = label;
16897 div.appendChild(radio);
16898 div.appendChild(lbl);
16899 div.addEventListener('click', function() {
16900 selectedScope = value;
16901 opts.querySelectorAll('.scope-option').forEach(function(o) {
16902 o.classList.toggle('selected', o.dataset.scopeValue === value);
16903 });
16904 });
16905 return div;
16906 }
16907
16908 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
16909 var sep = document.createElement('span');
16910 sep.className = 'scope-option-sep';
16911 opts.appendChild(sep);
16912 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
16913 subList.forEach(function(s) {
16914 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
16915 });
16916 }
16917
16918 function doCompare() {
16919 if (selected.length !== 2) return;
16920 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
16921 if (selectedScope === 'super') url += '&scope=super';
16922 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
16923 window.location.href = url;
16924 }
16925
16926 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
16927 var cbtn = document.getElementById('compare-btn');
16928 if (cbtn) cbtn.addEventListener('click', doCompare);
16929 var pfEl = document.getElementById('project-filter');
16930 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
16931 var bfEl = document.getElementById('branch-filter');
16932 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
16933 var rvBtn = document.getElementById('reset-view-btn');
16934 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
16935 var ppSel = document.getElementById('per-page-sel');
16936 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
16937
16938 var cmpTbody = document.getElementById('compare-tbody');
16939 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
16940 var row = e.target.closest('.compare-row');
16941 if (row) toggleRow(row);
16942 });
16943
16944 (function randomizeWatermarks() {
16945 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16946 if (!wms.length) return;
16947 var placed = [];
16948 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;}
16949 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];}
16950 var half=Math.floor(wms.length/2);
16951 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;});
16952 })();
16953
16954 (function spawnCodeParticles() {
16955 var container = document.getElementById('code-particles');
16956 if (!container) return;
16957 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'];
16958 for (var i = 0; i < 38; i++) {
16959 (function(idx) {
16960 var el = document.createElement('span');
16961 el.className = 'code-particle';
16962 el.textContent = snippets[idx % snippets.length];
16963 var left = Math.random() * 94 + 2;
16964 var top = Math.random() * 88 + 6;
16965 var dur = (Math.random() * 10 + 9).toFixed(1);
16966 var delay = (Math.random() * 18).toFixed(1);
16967 var rot = (Math.random() * 26 - 13).toFixed(1);
16968 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16969 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';
16970 container.appendChild(el);
16971 })(i);
16972 }
16973 })();
16974
16975 // ── Watched folder picker ─────────────────────────────────────────────
16976 (function() {
16977 var btn = document.getElementById('add-watched-btn');
16978 if (!btn) return;
16979 btn.addEventListener('click', function() {
16980 fetch('/pick-directory?kind=reports')
16981 .then(function(r) { return r.json(); })
16982 .then(function(data) {
16983 if (!data.cancelled && data.selected_path) {
16984 var form = document.createElement('form');
16985 form.method = 'POST';
16986 form.action = '/watched-dirs/add';
16987 var ri = document.createElement('input');
16988 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16989 var fi = document.createElement('input');
16990 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16991 form.appendChild(ri); form.appendChild(fi);
16992 document.body.appendChild(form);
16993 form.submit();
16994 }
16995 })
16996 .catch(function(e) { alert('Could not open folder picker: ' + e); });
16997 });
16998 })();
16999
17000 // ── Submodule chip truncation ─────────────────────────────────────────
17001 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17002 var chips = cell.querySelectorAll('.submod-chip');
17003 var MAX = 4;
17004 if (chips.length <= MAX) return;
17005 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17006 var badge = document.createElement('span');
17007 badge.className = 'submod-overflow-badge';
17008 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17009 badge.textContent = '+' + (chips.length - MAX) + ' more';
17010 cell.appendChild(badge);
17011 cell.style.maxHeight = 'none';
17012 });
17013 })();
17014 </script>
17015 <script nonce="{{ csp_nonce }}">
17016 (function(){
17017 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'}];
17018 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);});}
17019 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17020 function init(){
17021 var btn=document.getElementById('settings-btn');if(!btn)return;
17022 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17023 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>';
17024 document.body.appendChild(m);
17025 var g=document.getElementById('scheme-grid');
17026 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);});
17027 var cl=document.getElementById('settings-close');
17028 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);
17029 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');});
17030 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17031 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17032 }
17033 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17034 }());
17035 </script>
17036</body>
17037</html>
17038"##,
17039 ext = "html"
17040)]
17041struct CompareSelectTemplate {
17042 version: &'static str,
17043 entries: Vec<HistoryEntryRow>,
17044 total_scans: usize,
17045 watched_dirs: Vec<String>,
17046 csp_nonce: String,
17047}
17048
17049#[derive(Template)]
17052#[template(
17053 source = r##"
17054<!doctype html>
17055<html lang="en">
17056<head>
17057 <meta charset="utf-8">
17058 <meta name="viewport" content="width=device-width, initial-scale=1">
17059 <title>OxideSLOC | Scan Delta</title>
17060 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17061 <style nonce="{{ csp_nonce }}">
17062 :root {
17063 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17064 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17065 --nav:#283790; --nav-2:#013e6b;
17066 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17067 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17068 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17069 }
17070 body.dark-theme {
17071 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17072 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17073 }
17074 *{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);}
17075 .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);}
17076 .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;}
17077 .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));}
17078 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17079 .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;}
17080 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17081 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17082 @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; } }
17083 .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;}
17084 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17085 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17086 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17087 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17088 .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;}
17089 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17090 .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);}
17091 .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;}
17092 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17093 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17094 .settings-modal-body{padding:14px 16px 16px;}
17095 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17096 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17097 .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;}
17098 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17099 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17100 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17101 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17102 .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;}
17103 .tz-select:focus{border-color:var(--oxide);}
17104 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17105 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17106 .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;}
17107 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17108 .hero-body{display:block;}
17109 .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;}
17110 .btn-back:hover{background:var(--line);}
17111 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17112 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17113 .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;}
17114 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17115 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;}
17116 .muted{color:var(--muted);font-size:14px;}
17117 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17118 .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;}
17119 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17120 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17121 .vpill-arrow{font-size:20px;color:var(--muted);}
17122 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17123 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17124 .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;}
17125 .delta-card.delta-card-wide{padding:22px 24px;}
17126 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17127 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17128 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17129 .delta-card-from{font-size:15px;color:var(--muted);}
17130 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17131 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17132 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17133 .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%;}
17134 .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;}
17135 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17136 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17137 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17138 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17139 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17140 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17141 .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;}
17142 .meta-card-commit:hover{color:var(--oxide);}
17143 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17144 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17145 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17146 .meta-value{color:var(--text);font-size:13px;}
17147 .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;}
17148 .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);}
17149 .delta-card:hover .dc-tip{display:block;}
17150 .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;}
17151 .export-btn:hover{background:var(--line);}
17152 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17153 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17154 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17155 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17156 .delta-card-change.zero{color:var(--muted);background:transparent;}
17157 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17158 .delta-card-pct.pos{color:var(--pos);}
17159 .delta-card-pct.neg{color:var(--neg);}
17160 .delta-card-pct.zero{color:var(--muted);}
17161 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17162 .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;}
17163 .insight-card.insight-flag{border-color:var(--oxide);}
17164 .insight-card:hover .dc-tip{display:block;}
17165 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17166 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17167 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17168 .insight-label.flag{color:var(--oxide);}
17169 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17170 .insight-val.pos{color:var(--pos);}
17171 .insight-val.neg{color:var(--neg);}
17172 .insight-val.high{color:#c0392a;}
17173 .insight-val.med{color:#926000;}
17174 .insight-val.low{color:var(--pos);}
17175 body.dark-theme .insight-val.high{color:#ff6b6b;}
17176 body.dark-theme .insight-val.med{color:#f0c060;}
17177 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17178 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17179 .fc-row{display:flex;align-items:center;gap:8px;}
17180 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17181 .fc-label{color:var(--muted);}
17182 .fc-modified .fc-count{color:#926000;}
17183 .fc-added .fc-count{color:var(--pos);}
17184 .fc-removed .fc-count{color:var(--neg);}
17185 .fc-unchanged .fc-count{color:var(--muted);}
17186 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17187 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17188 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17189 .chip.modified{background:#fff2d8;color:#926000;}
17190 .chip.added{background:#e8f5ed;color:#1a8f47;}
17191 .chip.removed{background:#fdeaea;color:#b33b3b;}
17192 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17193 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17194 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17195 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17196 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17197 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17198 .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;}
17199 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17200 .tab-btn:hover:not(.active){background:var(--line);}
17201 .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;}
17202 .btn-reset:hover{background:var(--line);}
17203 .table-wrap{width:100%;overflow-x:auto;}
17204 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17205 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;}
17206 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17207 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17208 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17209 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17210 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17211 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17212 tr:last-child td{border-bottom:none;}
17213 tr.row-added td{background:rgba(26,143,71,0.06);}
17214 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17215 tr.row-modified td{background:rgba(146,96,0,0.05);}
17216 tr.row-unchanged td{opacity:.6;}
17217 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17218 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17219 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17220 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17221 .status-badge.modified{background:#fff2d8;color:#926000;}
17222 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17223 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17224 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17225 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17226 .delta-val{font-weight:700;}
17227 .delta-val.pos{color:var(--pos);}
17228 .delta-val.neg{color:var(--neg);}
17229 .delta-val.zero{color:var(--muted);}
17230 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17231 .from-to strong{color:var(--text);}
17232 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17233 .site-footer a{color:var(--muted);}
17234 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17235 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17236 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17237 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17238 .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;}
17239 .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;}
17240 .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;}
17241 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
17242 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17243 .path-link:hover{color:var(--oxide-2);}
17244 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17245 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17246 a.vpill-id:hover{color:var(--oxide);}
17247 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17248 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17249 .pagination-info{font-size:13px;color:var(--muted);}
17250 .pagination-btns{display:flex;gap:6px;}
17251 .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;}
17252 .pg-btn:hover:not(:disabled){background:var(--line);}
17253 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17254 .pg-btn:disabled{opacity:.35;cursor:default;}
17255 .per-page-label{font-size:13px;color:var(--muted);}
17256 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;}
17257 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17258 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17259 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17260 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17261 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17262 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17263 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17264 .tab-btn.tab-unchanged{color:var(--muted);}
17265 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17266 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17267 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17268 .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;}
17269 .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;}
17270 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17271 .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;}
17272 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17273 .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;}
17274 .submod-scope-btn:hover{background:var(--line);}
17275 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17276 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17277 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17278 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17279 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17280 body.dark-theme .ic-card{background:var(--surface-2);}
17281 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17282 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17283 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17284 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17285 #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;}
17286 </style>
17287</head>
17288<body>
17289 <div class="background-watermarks" aria-hidden="true">
17290 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17291 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17292 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17293 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17294 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17295 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17296 </div>
17297 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17298 <div class="top-nav">
17299 <div class="top-nav-inner">
17300 <a class="brand" href="/">
17301 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17302 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17303 </a>
17304 <div class="nav-right">
17305 <a class="nav-pill" href="/">Home</a>
17306 <div class="nav-dropdown">
17307 <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>
17308 <div class="nav-dropdown-menu">
17309 <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>
17310 </div>
17311 </div>
17312 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17313 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17314 <div class="nav-dropdown">
17315 <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>
17316 <div class="nav-dropdown-menu">
17317 <a href="/webhook-setup"><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>
17318 </div>
17319 </div>
17320 <div class="server-status-wrap">
17321 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17322 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
17323 </div>
17324 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17325 <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>
17326 </button>
17327 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17328 <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>
17329 <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>
17330 </button>
17331 </div>
17332 </div>
17333 </div>
17334
17335 <div class="page">
17336 <section class="hero">
17337 <div class="hero-header">
17338 <div>
17339 <h1 class="delta-title">Scan Delta</h1>
17340 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17341 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17342 {% if let Some(sub) = active_submodule %}
17343 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17344 {% else if super_scope_active %}
17345 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17346 {% else %}
17347 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17348 {% endif %}
17349 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17350 </div>
17351 </div>
17352 <a class="btn-back" href="/compare-scans">
17353 <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>
17354 Compare Scans
17355 </a>
17356 </div>
17357 {% if has_any_submodule_data %}
17358 <div class="submod-scope-bar">
17359 <span class="submod-scope-label">
17360 <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>
17361 Scope:
17362 </span>
17363 <div class="submod-scope-divider"></div>
17364 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17365 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
17366 title="All files — super-repo and all submodules combined">Full scan</a>
17367 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17368 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
17369 title="Only files that are not part of any submodule">Super-repo only</a>
17370 {% for sub in submodule_options %}
17371 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17372 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
17373 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17374 {% endfor %}
17375 </div>
17376 {% endif %}
17377 <div class="hero-body">
17378 <div class="meta-strip">
17379 <div class="delta-card delta-card-meta">
17380 <div class="meta-card-header">
17381 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17382 <div class="meta-card-project-col">
17383 <div class="meta-card-project">{{ project_name }}</div>
17384 {% if has_any_submodule_data %}
17385 {% if let Some(sub) = active_submodule %}
17386 <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>
17387 {% else if super_scope_active %}
17388 <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>
17389 {% else %}
17390 <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>
17391 {% endif %}
17392 {% endif %}
17393 </div>
17394 </div>
17395 {% if !baseline_git_commit.is_empty() %}
17396 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17397 {% else %}
17398 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17399 {% endif %}
17400 <div class="meta-card-rows">
17401 <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>
17402 <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>
17403 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
17404 <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>
17405 {% if let Some(tags) = baseline_git_tags %}
17406 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17407 {% endif %}
17408 </div>
17409 </div>
17410 <div class="delta-card delta-card-meta">
17411 <div class="meta-card-header">
17412 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17413 <div class="meta-card-project-col">
17414 <div class="meta-card-project">{{ project_name }}</div>
17415 {% if has_any_submodule_data %}
17416 {% if let Some(sub) = active_submodule %}
17417 <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>
17418 {% else if super_scope_active %}
17419 <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>
17420 {% else %}
17421 <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>
17422 {% endif %}
17423 {% endif %}
17424 </div>
17425 </div>
17426 {% if !current_git_commit.is_empty() %}
17427 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17428 {% else %}
17429 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17430 {% endif %}
17431 <div class="meta-card-rows">
17432 <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>
17433 <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>
17434 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
17435 <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>
17436 {% if let Some(tags) = current_git_tags %}
17437 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17438 {% endif %}
17439 </div>
17440 </div>
17441 </div>
17442 <div class="delta-strip">
17443 <div class="delta-card">
17444 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17445 <div class="delta-card-label">Code lines</div>
17446 <div class="delta-card-from">Before: {{ baseline_code }}</div>
17447 <div class="delta-card-to">{{ current_code }}</div>
17448 {% 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>
17449 {% 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>
17450 {% else %}<div class="delta-card-pct zero">±0%</div>
17451 {% endif %}
17452 </div>
17453 <div class="delta-card">
17454 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17455 <div class="delta-card-label">Files analyzed</div>
17456 <div class="delta-card-from">Before: {{ baseline_files }}</div>
17457 <div class="delta-card-to">{{ current_files }}</div>
17458 {% 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>
17459 {% 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>
17460 {% else %}<div class="delta-card-pct zero">±0%</div>
17461 {% endif %}
17462 </div>
17463 <div class="delta-card">
17464 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17465 <div class="delta-card-label">Comment lines</div>
17466 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17467 <div class="delta-card-to">{{ current_comments }}</div>
17468 {% 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>
17469 {% 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>
17470 {% else %}<div class="delta-card-pct zero">±0%</div>
17471 {% endif %}
17472 </div>
17473 <div class="delta-card delta-card-wide">
17474 <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>
17475 <div class="delta-card-label">File changes</div>
17476 <div class="file-changes-grid">
17477 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17478 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17479 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17480 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17481 </div>
17482 </div>
17483 </div>
17484 <div class="insights-panel">
17485 <div class="insight-card">
17486 <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>
17487 <div class="insight-label">Lines Added</div>
17488 <div class="insight-val pos">+{{ code_lines_added }}</div>
17489 <div class="insight-sub">New or grown source lines</div>
17490 </div>
17491 <div class="insight-card">
17492 <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>
17493 <div class="insight-label">Lines Removed</div>
17494 <div class="insight-val neg">−{{ code_lines_removed }}</div>
17495 <div class="insight-sub">Deleted or shrunk source lines</div>
17496 </div>
17497 <div class="insight-card">
17498 <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>
17499 <div class="insight-label">Churn Rate</div>
17500 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17501 <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>
17502 </div>
17503 {% if scope_flag %}
17504 <div class="insight-card insight-flag">
17505 <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>
17506 <div class="insight-label flag">Scope Signal</div>
17507 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17508 <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>
17509 </div>
17510 {% endif %}
17511 </div>
17512 </div>
17513 </section>
17514
17515 <section class="panel" id="inline-charts-section">
17516 <h2>Scan Delta Charts</h2>
17517 <div class="ic-grid">
17518 <div class="ic-card">
17519 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
17520 <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>
17521 <div id="ic-c1"></div>
17522 </div>
17523 <div class="ic-card" id="ic-lang-card">
17524 <div class="ic-card-h2">Language Code Delta</div>
17525 <div id="ic-c3"></div>
17526 </div>
17527 <div class="ic-card">
17528 <div class="ic-card-h2">Delta by Metric</div>
17529 <div id="ic-c2"></div>
17530 </div>
17531 <div class="ic-card">
17532 <div class="ic-card-h2">File Change Distribution</div>
17533 <div id="ic-c4"></div>
17534 </div>
17535 </div>
17536 </section>
17537
17538 <section class="panel">
17539 <h2>File-level delta</h2>
17540 <div class="filter-tabs-row">
17541 <div class="filter-tabs">
17542 <button class="tab-btn tab-all active" data-filter="all">All</button>
17543 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17544 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17545 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17546 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17547 </div>
17548 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17549 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
17550 <div class="export-group">
17551 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
17552 <button type="button" class="export-btn" id="delta-csv-btn">
17553 <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>
17554 CSV
17555 </button>
17556 <button type="button" class="export-btn" id="delta-xls-btn">
17557 <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>
17558 Excel
17559 </button>
17560 <button type="button" class="export-btn" id="delta-charts-btn">
17561 <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>
17562 Charts
17563 </button>
17564 </div>
17565 </div>
17566 </div>
17567
17568 <div class="table-wrap">
17569 <table id="delta-table">
17570 <colgroup>
17571 <col>
17572 <col>
17573 <col>
17574 <col>
17575 <col>
17576 <col>
17577 <col>
17578 </colgroup>
17579 <thead>
17580 <tr id="delta-thead">
17581 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17582 <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>
17583 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17584 <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>
17585 <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>
17586 <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>
17587 <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>
17588 </tr>
17589 </thead>
17590 <tbody id="delta-tbody">
17591 {% for row in file_rows %}
17592 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17593 data-path="{{ row.relative_path }}"
17594 data-language="{{ row.language }}"
17595 data-baseline-code="{{ row.baseline_code }}"
17596 data-current-code="{{ row.current_code }}"
17597 data-code-delta="{{ row.code_delta_str }}"
17598 data-comment-delta="{{ row.comment_delta_str }}"
17599 data-total-delta="{{ row.total_delta_str }}"
17600 data-orig-idx="">
17601 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17602 <td class="hide-sm">{{ row.language }}</td>
17603 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17604 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17605 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17606 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17607 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17608 </tr>
17609 {% endfor %}
17610 </tbody>
17611 </table>
17612 </div>
17613 <div class="pagination">
17614 <span class="pagination-info" id="pg-info"></span>
17615 <div class="pagination-btns" id="pg-btns"></div>
17616 <div class="flex-row">
17617 <span class="per-page-label">Show</span>
17618 <select class="per-page" id="per-page-sel">
17619 <option value="10">10 per page</option>
17620 <option value="25" selected>25 per page</option>
17621 <option value="50">50 per page</option>
17622 <option value="100">100 per page</option>
17623 </select>
17624 <span class="per-page-label" id="pg-range-label"></span>
17625 </div>
17626 </div>
17627 </section>
17628 </div>
17629
17630 <div id="ic-tt"></div>
17631
17632 <footer class="site-footer">
17633 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
17634 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17635 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17636 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17637 · <a href="/api-docs" rel="noopener">REST API</a>
17638 </footer>
17639
17640 <script nonce="{{ csp_nonce }}">
17641 (function () {
17642 var storageKey = 'oxide-sloc-theme';
17643 var body = document.body;
17644 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17645 var toggle = document.getElementById('theme-toggle');
17646 if (toggle) toggle.addEventListener('click', function () {
17647 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17648 body.classList.toggle('dark-theme', next === 'dark');
17649 try { localStorage.setItem(storageKey, next); } catch(e) {}
17650 });
17651
17652 (function randomizeWatermarks() {
17653 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17654 if (!wms.length) return;
17655 var placed = [];
17656 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;}
17657 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];}
17658 var half=Math.floor(wms.length/2);
17659 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;});
17660 })();
17661
17662 (function spawnCodeParticles() {
17663 var container = document.getElementById('code-particles');
17664 if (!container) return;
17665 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'];
17666 for (var i = 0; i < 38; i++) {
17667 (function(idx) {
17668 var el = document.createElement('span');
17669 el.className = 'code-particle';
17670 el.textContent = snippets[idx % snippets.length];
17671 var left = Math.random() * 94 + 2;
17672 var top = Math.random() * 88 + 6;
17673 var dur = (Math.random() * 10 + 9).toFixed(1);
17674 var delay = (Math.random() * 18).toFixed(1);
17675 var rot = (Math.random() * 26 - 13).toFixed(1);
17676 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17677 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';
17678 container.appendChild(el);
17679 })(i);
17680 }
17681 })();
17682 })();
17683
17684 var activeStatusFilter = 'all';
17685 var deltaPerPage = 25, deltaCurrPage = 1;
17686
17687 function openFolder(path) {
17688 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17689 }
17690
17691 function getDeltaFilteredRows() {
17692 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17693 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17694 });
17695 }
17696
17697 function renderDeltaPage() {
17698 var filtered = getDeltaFilteredRows();
17699 var total = filtered.length;
17700 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17701 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17702 var start = (deltaCurrPage - 1) * deltaPerPage;
17703 var end = Math.min(start + deltaPerPage, total);
17704 var shownSet = {};
17705 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17706 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17707 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17708 });
17709 var rl = document.getElementById('pg-range-label');
17710 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17711 var info = document.getElementById('pg-info');
17712 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17713 var btns = document.getElementById('pg-btns');
17714 if (!btns) return;
17715 btns.innerHTML = '';
17716 if (totalPages <= 1) return;
17717 function makeBtn(lbl, pg, active, disabled) {
17718 var b = document.createElement('button');
17719 b.className = 'pg-btn' + (active ? ' active' : '');
17720 b.textContent = lbl; b.disabled = disabled;
17721 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17722 return b;
17723 }
17724 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17725 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17726 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17727 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17728 }
17729
17730 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17731
17732 function filterRows(status, btn) {
17733 activeStatusFilter = status;
17734 deltaCurrPage = 1;
17735 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17736 b.classList.remove('active');
17737 });
17738 if (btn) btn.classList.add('active');
17739 renderDeltaPage();
17740 }
17741
17742 // ── Sorting ──────────────────────────────────────────────────────────────
17743 var sortCol = null, sortOrder = 'asc';
17744 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17745 (function() {
17746 var tbody = document.getElementById('delta-tbody');
17747 if (!tbody) return;
17748 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17749 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17750 })();
17751
17752 function parseDeltaNum(str) {
17753 if (!str || str === '—') return 0;
17754 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17755 }
17756
17757 sortHeaders.forEach(function(th) {
17758 th.addEventListener('click', function(e) {
17759 if (e.target.classList.contains('col-resize-handle')) return;
17760 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17761 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17762 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17763 th.classList.add('sort-' + sortOrder);
17764 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17765 var tbody = document.getElementById('delta-tbody');
17766 if (!tbody) return;
17767 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17768 rows.sort(function(a, b) {
17769 var va, vb;
17770 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17771 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17772 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17773 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17774 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17775 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17776 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17777 else { va = ''; vb = ''; }
17778 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
17779 return va < vb ? 1 : va > vb ? -1 : 0;
17780 });
17781 rows.forEach(function(r) { tbody.appendChild(r); });
17782 deltaCurrPage = 1;
17783 renderDeltaPage();
17784 var activeBtn = document.querySelector('.tab-btn.active');
17785 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17786 if (activeBtn) activeBtn.classList.add('active');
17787 });
17788 });
17789
17790 // ── Column resize ─────────────────────────────────────────────────────────
17791 (function() {
17792 var table = document.getElementById('delta-table');
17793 if (!table) return;
17794 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17795 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
17796 ths.forEach(function(th, i) {
17797 var handle = th.querySelector('.col-resize-handle');
17798 if (!handle || !cols[i]) return;
17799 var startX, startW;
17800 handle.addEventListener('mousedown', function(e) {
17801 e.stopPropagation(); e.preventDefault();
17802 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17803 handle.classList.add('dragging');
17804 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17805 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17806 document.addEventListener('mousemove', onMove);
17807 document.addEventListener('mouseup', onUp);
17808 });
17809 });
17810 })();
17811
17812 // ── Reset ─────────────────────────────────────────────────────────────────
17813 window.resetDeltaTable = function() {
17814 sortCol = null; sortOrder = 'asc';
17815 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17816 var tbody = document.getElementById('delta-tbody');
17817 if (tbody) {
17818 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17819 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17820 rows.forEach(function(r) { tbody.appendChild(r); });
17821 }
17822 var table = document.getElementById('delta-table');
17823 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
17824 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
17825 activeStatusFilter = 'all';
17826 deltaCurrPage = 1;
17827 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17828 var allBtn = document.querySelector('.tab-btn');
17829 if (allBtn) allBtn.classList.add('active');
17830 renderDeltaPage();
17831 };
17832
17833 renderDeltaPage();
17834
17835 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
17836 (function() {
17837 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
17838 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
17839 });
17840 var resetBtn = document.getElementById('delta-reset-btn');
17841 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
17842 var csvBtn = document.getElementById('delta-csv-btn');
17843 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
17844 var xlsBtn = document.getElementById('delta-xls-btn');
17845 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
17846 var chartsBtn = document.getElementById('delta-charts-btn');
17847 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
17848 var ppSel = document.getElementById('per-page-sel');
17849 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
17850 var pathLink = document.getElementById('project-path-link');
17851 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
17852 })();
17853
17854 // ── Export helpers ────────────────────────────────────────────────────────
17855 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
17856 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
17857 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);}
17858 function slocMakeXlsx(fname,sd,dr){
17859 var enc=new TextEncoder();
17860 // CRC-32 table
17861 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;}
17862 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;}
17863 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
17864 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
17865 // Shared string table
17866 var ss=[],si={};
17867 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
17868 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
17869 // Worksheet builder — each WS() call gets its own row counter R
17870 function WS(){
17871 var R=0,buf=[];
17872 function cl(c){return String.fromCharCode(65+c);}
17873 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
17874 '<v>'+S(v)+'</v></c>';}
17875 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
17876 (st?' s="'+st+'"':'')+'>'+
17877 '<v>'+(+v)+'</v></c>';}
17878 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
17879 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17880 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
17881 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
17882 '<sheetFormatPr defaultRowHeight="15"/>'+
17883 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
17884 return{sc:sc,nc:nc,row:row,xml:xml};
17885 }
17886 // Language breakdown
17887 var lm={};
17888 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;});
17889 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
17890 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
17891 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
17892 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
17893 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17894 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17895 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):'';}
17896 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
17897 // Summary sheet
17898 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
17899 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
17900 r1(s1(0,proj,2));
17901 r1(s1(0,sd.bts+' → '+sd.cts,2));
17902 r1('');
17903 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
17904 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))));
17905 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))));
17906 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))));
17907 r1('');
17908 r1(s1(0,'FILE CHANGES',8));
17909 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
17910 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
17911 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
17912 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
17913 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
17914 if(langs.length){
17915 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
17916 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
17917 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)));});
17918 }
17919 r1('');r1(s1(0,'SCAN METADATA',8));
17920 r1(s1(1,_blabel)+s1(2,_clabel));
17921 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
17922 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
17923 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"/>');
17924 // File Delta sheet
17925 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
17926 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));
17927 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)));});
17928 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
17929 // Shared strings XML
17930 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17931 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
17932 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
17933 // XLSX file map
17934 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
17935 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>',
17936 '_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>',
17937 '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>',
17938 '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>',
17939 '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>',
17940 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
17941 // ZIP packer — STORED (no compression), compatible with all XLSX readers
17942 var zparts=[],zcds=[],zoff=0,znf=0;
17943 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
17944 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
17945 ].forEach(function(name){
17946 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
17947 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]);
17948 var entry=new Uint8Array(lha.length+nb.length+sz);
17949 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
17950 zparts.push(entry);
17951 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));
17952 var cde=new Uint8Array(cda.length+nb.length);
17953 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
17954 zcds.push(cde);zoff+=entry.length;znf++;
17955 });
17956 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
17957 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]);
17958 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
17959 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
17960 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
17961 zout.set(new Uint8Array(ea),zpos);
17962 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
17963 var xurl=URL.createObjectURL(xblob);
17964 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
17965 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
17966 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
17967 }
17968 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;');}
17969 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
17970 function getExportFilename(ext){return _exportBase+'.'+ext;}
17971
17972 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 }}'};
17973 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;}
17974 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
17975 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
17976 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17977 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17978 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):'';}
17979 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
17980 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)]];}
17981 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
17982 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;}
17983 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
17984 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
17985
17986 // ── Chart HTML report ─────────────────────────────────────────────────────
17987 function slocChartReport(fname, sd, dr) {
17988 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
17989 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
17990 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
17991 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();}
17992 function px(n){return Math.round(n);}
17993 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
17994 // Language map
17995 var lm={};
17996 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;});
17997 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
17998
17999 // Builds onmouse* attrs for interactive tooltip on each SVG element
18000 function barTT(label,val){
18001 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18002 }
18003
18004 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18005 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'}];
18006 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18007 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18008 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18009 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18010 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"/>';}
18011 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18012 c1mets.forEach(function(m,i){
18013 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18014 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18015 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>';
18016 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))+'/>';
18017 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>';
18018 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))+'/>';
18019 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>';
18020 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>';
18021 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>';
18022 });
18023 c1+='</svg>';
18024
18025 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18026 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'}];
18027 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18028 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18029 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18030 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18031 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18032 mets.forEach(function(m,i){
18033 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18034 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18035 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18036 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>';
18037 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18038 if(bw>=52){
18039 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>';
18040 }else{
18041 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18042 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>';
18043 }
18044 });
18045 c2+='</svg>';
18046
18047 // ── Chart 3: Language Code Delta ─────────────────────────────────────
18048 var c3='';
18049 if(langs.length){
18050 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18051 var C3W=550,c3LW=124,c3FW=52;
18052 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18053 var L3rH=30,C3H=langs.length*L3rH+20;
18054 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18055 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18056 langs.forEach(function(l,i){
18057 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18058 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18059 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18060 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18061 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':''))+'/>';
18062 if(bw>=48){
18063 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>';
18064 }else{
18065 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18066 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>';
18067 }
18068 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>';
18069 });
18070 c3+='</svg>';
18071 }
18072
18073 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18074 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;});
18075 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18076 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18077 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18078 var ang=-Math.PI/2;
18079 segs.forEach(function(s){
18080 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18081 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18082 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18083 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18084 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18085 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)+'%')+'/>';
18086 ang+=sw;
18087 });
18088 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>';
18089 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18090 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>';});
18091 c4+='</svg>';
18092
18093 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18094 var ttJs='var tt=document.getElementById("ox-tt");'+
18095 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18096 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18097 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18098 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18099 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18100 'function oxHT(){tt.style.display="none";}';
18101
18102 // body max-width keeps charts from inflating beyond design dimensions on
18103 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18104 // each chart's height blows up proportionally, breaking the one-page layout.
18105 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;}'+
18106 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18107 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18108 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18109 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18110 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18111 'svg{display:block;}'+
18112 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18113 '#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;}'+
18114 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18115 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18116 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18117 '<div id="ox-tt"><\/div>'+
18118 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
18119 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
18120 '<div class="two-col">'+
18121 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
18122 '<div class="leg">'+
18123 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18124 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18125 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18126 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
18127 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18128 '<\/div>'+
18129 '<div class="two-col">'+
18130 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18131 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18132 '<\/div>'+
18133 '<script>'+ttJs+'<\/script>'+
18134 '<\/body><\/html>';
18135 slocDownload(html, fname, 'text/html;charset=utf-8;');
18136 }
18137 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18138 // ── Inline delta charts ────────────────────────────────────────────────────
18139 var _icTT=document.getElementById('ic-tt');
18140 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18141 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';};
18142 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18143 (function(){
18144 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18145 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18146 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();}
18147 function px(n){return Math.round(n);}
18148 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18149 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
18150 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);});}
18151 var dr=getDeltaExportRows(),sd=_sd,lm={};
18152 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;});
18153 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18154 // Chart 1: Baseline vs Current grouped bars
18155 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'}];
18156 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18157 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;
18158 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18159 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"/>';}
18160 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18161 c1mets.forEach(function(m,i){
18162 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18163 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18164 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>';
18165 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"/>';
18166 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>';
18167 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"/>';
18168 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>';
18169 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>';
18170 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>';
18171 });
18172 c1+='</svg>';
18173 // Chart 2: Delta by Metric
18174 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'}];
18175 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18176 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;
18177 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18178 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18179 mets.forEach(function(m,i){
18180 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);
18181 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>';
18182 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18183 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>';}
18184 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>';}
18185 });
18186 c2+='</svg>';
18187 // Chart 3: Language Code Delta
18188 var c3='';
18189 if(langs.length){
18190 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18191 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;
18192 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18193 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18194 langs.forEach(function(l,i){
18195 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);
18196 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18197 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"/>';
18198 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>';}
18199 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>';}
18200 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>';
18201 });
18202 c3+='</svg>';
18203 }
18204 // Chart 4: File Change Donut
18205 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;});
18206 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18207 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;
18208 if(segs.length===1){
18209 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18210 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18211 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18212 } else {
18213 segs.forEach(function(s){
18214 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18215 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);
18216 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);
18217 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"/>';
18218 ang+=sw;
18219 });
18220 }
18221 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>';
18222 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18223 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>';});
18224 c4+='</svg>';
18225 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
18226 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
18227 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);}
18228 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
18229 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18230 })();
18231 </script>
18232 <script nonce="{{ csp_nonce }}">
18233 (function(){
18234 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'}];
18235 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);});}
18236 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18237 function init(){
18238 var btn=document.getElementById('settings-btn');if(!btn)return;
18239 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18240 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>';
18241 document.body.appendChild(m);
18242 var g=document.getElementById('scheme-grid');
18243 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);});
18244 var cl=document.getElementById('settings-close');
18245 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);
18246 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');});
18247 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18248 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18249 }
18250 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18251 }());
18252 </script>
18253</body>
18254</html>
18255"##,
18256 ext = "html"
18257)]
18258#[allow(clippy::struct_excessive_bools)]
18260struct CompareTemplate {
18261 version: &'static str,
18262 project_label: String,
18263 baseline_git_commit: String,
18264 current_git_commit: String,
18265 baseline_run_id: String,
18266 current_run_id: String,
18267 baseline_run_id_short: String,
18268 current_run_id_short: String,
18269 baseline_timestamp: String,
18270 baseline_timestamp_utc_ms: i64,
18271 current_timestamp: String,
18272 current_timestamp_utc_ms: i64,
18273 project_path: String,
18274 baseline_code: u64,
18275 current_code: u64,
18276 code_lines_delta_str: String,
18277 code_lines_delta_class: String,
18278 baseline_files: u64,
18279 current_files: u64,
18280 files_analyzed_delta_str: String,
18281 files_analyzed_delta_class: String,
18282 baseline_comments: u64,
18283 current_comments: u64,
18284 comment_lines_delta_str: String,
18285 comment_lines_delta_class: String,
18286 code_lines_pct_str: String,
18287 files_analyzed_pct_str: String,
18288 comment_lines_pct_str: String,
18289 code_lines_added: i64,
18290 code_lines_removed: i64,
18291 new_scope: bool,
18293 churn_rate_str: String,
18294 churn_rate_class: String,
18295 scope_flag: bool,
18296 files_added: usize,
18297 files_removed: usize,
18298 files_modified: usize,
18299 files_unchanged: usize,
18300 file_rows: Vec<CompareFileDeltaRow>,
18301 baseline_git_author: Option<String>,
18302 current_git_author: Option<String>,
18303 baseline_git_branch: String,
18304 current_git_branch: String,
18305 baseline_git_tags: Option<String>,
18306 current_git_tags: Option<String>,
18307 baseline_git_commit_date: Option<String>,
18308 current_git_commit_date: Option<String>,
18309 project_name: String,
18310 submodule_options: Vec<String>,
18312 has_any_submodule_data: bool,
18314 active_submodule: Option<String>,
18316 super_scope_active: bool,
18318 csp_nonce: String,
18319}
18320
18321#[derive(Template)]
18324#[template(
18325 source = r##"
18326<!doctype html>
18327<html lang="en">
18328<head>
18329 <meta charset="utf-8">
18330 <meta name="viewport" content="width=device-width, initial-scale=1">
18331 <title>OxideSLOC | Sign In</title>
18332 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18333 <style nonce="{{ csp_nonce }}">
18334 :root {
18335 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18336 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18337 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18338 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18339 }
18340 *{box-sizing:border-box;}
18341 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);}
18342 .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);}
18343 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18344 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18345 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18346 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18347 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18348 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18349 .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;}
18350 @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));}}
18351 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18352 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18353 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18354 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18355 .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;}
18356 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18357 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;}
18358 input[type=password]:focus{border-color:var(--oxide);}
18359 .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;}
18360 .btn:hover{opacity:.88;}
18361 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18362 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18363 </style>
18364</head>
18365<body>
18366 <div class="background-watermarks" aria-hidden="true">
18367 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18368 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18369 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18370 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18371 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18372 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18373 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18374 </div>
18375 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18376<nav class="top-nav">
18377 <a class="brand" href="/">
18378 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18379 <span class="brand-title">OxideSLOC</span>
18380 </a>
18381</nav>
18382<main class="page">
18383 <div class="card">
18384 <h1>Sign In</h1>
18385 <p class="subtitle">Enter the API key printed when the server started.</p>
18386 {% if has_error %}
18387 <div class="error">Incorrect API key — please try again.</div>
18388 {% endif %}
18389 <form method="POST" action="/auth/login">
18390 <input type="hidden" name="next" value="{{ next_url|e }}">
18391 <label for="key">API Key</label>
18392 <input id="key" type="password" name="key" autocomplete="current-password"
18393 placeholder="Paste your API key here" autofocus>
18394 <button type="submit" class="btn">Sign In</button>
18395 </form>
18396 <p class="hint">
18397 The API key was printed in the terminal when the server started.<br>
18398 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18399 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18400 </p>
18401 </div>
18402</main>
18403<script nonce="{{ csp_nonce }}">
18404(function() {
18405 (function randomizeWatermarks() {
18406 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18407 if (!wms.length) return;
18408 var placed = [];
18409 function tooClose(top, left) {
18410 for (var i = 0; i < placed.length; i++) {
18411 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18412 if (dt < 16 && dl < 12) return true;
18413 }
18414 return false;
18415 }
18416 function pick(leftBand) {
18417 for (var attempt = 0; attempt < 50; attempt++) {
18418 var top = Math.random() * 88 + 2;
18419 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18420 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18421 }
18422 var top = Math.random() * 88 + 2;
18423 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18424 placed.push([top, left]); return [top, left];
18425 }
18426 var half = Math.floor(wms.length / 2);
18427 wms.forEach(function (img, i) {
18428 var pos = pick(i < half);
18429 var size = Math.floor(Math.random() * 100 + 120);
18430 var rot = (Math.random() * 360).toFixed(1);
18431 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18432 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;
18433 });
18434 })();
18435 (function spawnCodeParticles() {
18436 var container = document.getElementById('code-particles');
18437 if (!container) return;
18438 var snippets = [
18439 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18440 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18441 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18442 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18443 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18444 ];
18445 var count = 38;
18446 for (var i = 0; i < count; i++) {
18447 (function(idx) {
18448 var el = document.createElement('span');
18449 el.className = 'code-particle';
18450 el.textContent = snippets[idx % snippets.length];
18451 var left = Math.random() * 94 + 2;
18452 var top = Math.random() * 88 + 6;
18453 var dur = (Math.random() * 10 + 9).toFixed(1);
18454 var delay = (Math.random() * 18).toFixed(1);
18455 var rot = (Math.random() * 26 - 13).toFixed(1);
18456 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18457 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18458 container.appendChild(el);
18459 })(i);
18460 }
18461 })();
18462})();
18463</script>
18464</body>
18465</html>
18466"##,
18467 ext = "html"
18468)]
18469pub(crate) struct LoginTemplate {
18470 pub(crate) csp_nonce: String,
18471 pub(crate) has_error: bool,
18472 pub(crate) next_url: String,
18473 pub(crate) lockout_threshold: u32,
18474}
18475
18476#[derive(Template)]
18479#[template(
18480 source = r##"
18481<!doctype html>
18482<html lang="en">
18483<head>
18484 <meta charset="utf-8">
18485 <meta name="viewport" content="width=device-width, initial-scale=1">
18486 <title>OxideSLOC — REST API Reference</title>
18487 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18488 <style nonce="{{ csp_nonce }}">
18489 :root {
18490 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18491 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18492 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18493 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18494 --success:#16a34a;
18495 }
18496 body.dark-theme {
18497 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18498 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18499 }
18500 *{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);}
18501 .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);}
18502 .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;}
18503 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18504 .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));}
18505 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18506 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18507 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18508 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18509 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18510 @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; } }
18511 .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;}
18512 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18513 .nav-pill.active{background:rgba(255,255,255,0.22);}
18514 .nav-dropdown{position:relative;display:inline-flex;}
18515 .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;}
18516 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18517 .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;}
18518 .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;}
18519 .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);}
18520 .nav-dropdown-menu a:last-child{border-bottom:none;}
18521 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18522 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18523 .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;}
18524 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18525 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18526 .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;}
18527 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18528 .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);}
18529 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18530 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18531 .settings-modal-body{padding:14px 16px 16px;}
18532 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18533 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18534 .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;}
18535 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18536 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18537 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18538 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18539 .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;}
18540 .tz-select:focus{border-color:var(--oxide);}
18541 .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18542 .page-header{margin-bottom:28px;}
18543 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18544 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18545 .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;}
18546 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18547 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18548 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18549 .callout strong{font-weight:800;}
18550 .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;}
18551 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18552 .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;}
18553 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18554 .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;}
18555 body.dark-theme .base-url-value{color:var(--accent);}
18556 .section{margin-bottom:36px;}
18557 .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);}
18558 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18559 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18560 .ep-header:hover{background:var(--surface-2);}
18561 .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;}
18562 .method.get{background:#dcfce7;color:#166534;}
18563 .method.post{background:#dbeafe;color:#1e40af;}
18564 .method.delete{background:#fee2e2;color:#991b1b;}
18565 body.dark-theme .method.get{background:#14532d;color:#86efac;}
18566 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18567 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18568 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18569 .ep-path .param{color:var(--oxide-2);}
18570 body.dark-theme .ep-path .param{color:var(--oxide);}
18571 .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;}
18572 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18573 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18574 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18575 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18576 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18577 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18578 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18579 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18580 .ep-card.open .chevron{transform:rotate(180deg);}
18581 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18582 .ep-card.open .ep-body{display:block;}
18583 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18584 .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;}
18585 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18586 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18587 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18588 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18589 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);}
18590 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18591 table.params tr:last-child td{border-bottom:none;}
18592 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18593 .pt-type{color:var(--muted-2);font-size:12px;}
18594 .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;}
18595 .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;}
18596 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18597 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18598 details.schema{margin-bottom:14px;}
18599 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;}
18600 details.schema summary:hover{color:var(--text);}
18601 .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;}
18602 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18603 .curl-wrap{position:relative;}
18604 .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;}
18605 .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;}
18606 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18607 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18608 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18609 .webhook-note a{color:var(--accent-2);text-decoration:none;}
18610 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18611 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18612 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18613 .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;}
18614 @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));}}
18615 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18616 .site-footer a{color:var(--muted);}
18617 </style>
18618</head>
18619<body>
18620 <div class="background-watermarks" aria-hidden="true">
18621 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18622 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18623 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18624 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18625 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18626 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18627 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18628 </div>
18629 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18630 <div class="top-nav">
18631 <div class="top-nav-inner">
18632 <a class="brand" href="/">
18633 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18634 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18635 </a>
18636 <div class="nav-right">
18637 <a class="nav-pill" href="/">Home</a>
18638 <div class="nav-dropdown">
18639 <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>
18640 <div class="nav-dropdown-menu">
18641 <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>
18642 </div>
18643 </div>
18644 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18645 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18646 <div class="nav-dropdown">
18647 <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>
18648 <div class="nav-dropdown-menu">
18649 <a href="/webhook-setup"><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>
18650 </div>
18651 </div>
18652 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18653 <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>
18654 </button>
18655 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18656 <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>
18657 <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>
18658 </button>
18659 </div>
18660 </div>
18661 </div>
18662
18663 <div class="page">
18664 <div class="page-header">
18665 <h1 class="page-title">REST API Reference</h1>
18666 <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>
18667 </div>
18668
18669 {% if has_api_key %}
18670 <div class="callout key-set">
18671 <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>
18672 <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>
18673 </div>
18674 {% else %}
18675 <div class="callout no-key">
18676 <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>
18677 <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>
18678 </div>
18679 {% endif %}
18680
18681 <div class="base-url-bar">
18682 <span class="base-url-label">Base URL</span>
18683 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18684 </div>
18685
18686 <!-- Health -->
18687 <div class="section">
18688 <h2 class="section-title">Health & Status</h2>
18689 <div class="ep-card">
18690 <div class="ep-header">
18691 <span class="method get">GET</span>
18692 <span class="ep-path">/healthz</span>
18693 <span class="auth-badge public">Public</span>
18694 <span class="ep-desc">Server liveness check</span>
18695 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18696 </div>
18697 <div class="ep-body">
18698 <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>
18699 <p class="params-heading">Response</p>
18700 <div class="schema-block">200 OK
18701Content-Type: text/plain
18702
18703ok</div>
18704 <p class="curl-heading">Example</p>
18705 <div class="curl-wrap">
18706 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18707 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18708 </div>
18709 </div>
18710 </div>
18711 </div>
18712
18713 <!-- Badges -->
18714 <div class="section">
18715 <h2 class="section-title">Badges</h2>
18716 <div class="ep-card">
18717 <div class="ep-header">
18718 <span class="method get">GET</span>
18719 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18720 <span class="auth-badge public">Public</span>
18721 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18722 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18723 </div>
18724 <div class="ep-body">
18725 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18726 <p class="params-heading">Path Parameters</p>
18727 <table class="params">
18728 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18729 <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>
18730 </table>
18731 <p class="curl-heading">Example</p>
18732 <div class="curl-wrap">
18733 <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>
18734 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18735 </div>
18736 </div>
18737 </div>
18738 </div>
18739
18740 <!-- Metrics -->
18741 <div class="section">
18742 <h2 class="section-title">Metrics</h2>
18743
18744 <div class="ep-card">
18745 <div class="ep-header">
18746 <span class="method get">GET</span>
18747 <span class="ep-path">/api/metrics/latest</span>
18748 <span class="auth-badge protected">Protected</span>
18749 <span class="ep-desc">Latest scan metrics (JSON)</span>
18750 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18751 </div>
18752 <div class="ep-body">
18753 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18754 <details class="schema"><summary>Response schema</summary>
18755<div class="schema-block">{
18756 "run_id": string, // UUID
18757 "timestamp": string, // ISO-8601 UTC
18758 "project": string, // scanned root path
18759 "summary": {
18760 "files_analyzed": number,
18761 "files_skipped": number,
18762 "code_lines": number,
18763 "comment_lines": number,
18764 "blank_lines": number,
18765 "total_physical_lines": number,
18766 "functions": number,
18767 "classes": number,
18768 "variables": number,
18769 "imports": number
18770 },
18771 "languages": [
18772 { "name": string, "files": number, "code_lines": number,
18773 "comment_lines": number, "blank_lines": number,
18774 "functions": number, "classes": number,
18775 "variables": number, "imports": number }
18776 ]
18777}</div></details>
18778 <p class="curl-heading">Example</p>
18779 <div class="curl-wrap">
18780 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18781 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
18782 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
18783 </div>
18784 </div>
18785 </div>
18786
18787 <div class="ep-card">
18788 <div class="ep-header">
18789 <span class="method get">GET</span>
18790 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
18791 <span class="auth-badge protected">Protected</span>
18792 <span class="ep-desc">Metrics for a specific run</span>
18793 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18794 </div>
18795 <div class="ep-body">
18796 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
18797 <p class="params-heading">Path Parameters</p>
18798 <table class="params">
18799 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18800 <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>
18801 </table>
18802 <p class="curl-heading">Example</p>
18803 <div class="curl-wrap">
18804 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18805 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
18806 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
18807 </div>
18808 </div>
18809 </div>
18810
18811 <div class="ep-card">
18812 <div class="ep-header">
18813 <span class="method get">GET</span>
18814 <span class="ep-path">/api/metrics/history</span>
18815 <span class="auth-badge protected">Protected</span>
18816 <span class="ep-desc">Paginated scan history</span>
18817 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18818 </div>
18819 <div class="ep-body">
18820 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
18821 <p class="params-heading">Query Parameters</p>
18822 <table class="params">
18823 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18824 <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>
18825 <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>
18826 </table>
18827 <details class="schema"><summary>Response schema</summary>
18828<div class="schema-block">[{
18829 "run_id": string,
18830 "timestamp": string, // ISO-8601 UTC
18831 "commit": string | null,
18832 "branch": string | null,
18833 "tags": string[],
18834 "code_lines": number,
18835 "comment_lines": number,
18836 "blank_lines": number,
18837 "physical_lines": number,
18838 "files_analyzed": number,
18839 "project_label": string,
18840 "html_url": string | null
18841}]</div></details>
18842 <p class="curl-heading">Example</p>
18843 <div class="curl-wrap">
18844 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18845 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
18846 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
18847 </div>
18848 </div>
18849 </div>
18850
18851 <div class="ep-card">
18852 <div class="ep-header">
18853 <span class="method get">GET</span>
18854 <span class="ep-path">/api/project-history</span>
18855 <span class="auth-badge protected">Protected</span>
18856 <span class="ep-desc">Project-level scan summary</span>
18857 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18858 </div>
18859 <div class="ep-body">
18860 <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>
18861 <p class="params-heading">Query Parameters</p>
18862 <table class="params">
18863 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18864 <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>
18865 </table>
18866 <details class="schema"><summary>Response schema</summary>
18867<div class="schema-block">{
18868 "scan_count": number,
18869 "last_scan_id": string | null,
18870 "last_scan_timestamp": string | null, // ISO-8601
18871 "last_scan_code_lines": number | null,
18872 "last_git_branch": string | null,
18873 "last_git_commit": string | null
18874}</div></details>
18875 <p class="curl-heading">Example</p>
18876 <div class="curl-wrap">
18877 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18878 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
18879 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
18880 </div>
18881 </div>
18882 </div>
18883
18884 <div class="ep-card">
18885 <div class="ep-header">
18886 <span class="method get">GET</span>
18887 <span class="ep-path">/api/metrics/submodules</span>
18888 <span class="auth-badge protected">Protected</span>
18889 <span class="ep-desc">List known git submodules across scans</span>
18890 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18891 </div>
18892 <div class="ep-body">
18893 <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>
18894 <p class="params-heading">Query Parameters</p>
18895 <table class="params">
18896 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18897 <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>
18898 </table>
18899 <details class="schema"><summary>Response schema</summary>
18900<div class="schema-block">[{
18901 "name": string, // submodule name
18902 "relative_path": string // path relative to the project root
18903}]</div></details>
18904 <p class="curl-heading">Example</p>
18905 <div class="curl-wrap">
18906 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18907 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
18908 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
18909 </div>
18910 </div>
18911 </div>
18912 </div>
18913
18914 <!-- Async Run Status -->
18915 <div class="section">
18916 <h2 class="section-title">Async Run Status</h2>
18917
18918 <div class="ep-card">
18919 <div class="ep-header">
18920 <span class="method get">GET</span>
18921 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
18922 <span class="auth-badge protected">Protected</span>
18923 <span class="ep-desc">Poll scan completion</span>
18924 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18925 </div>
18926 <div class="ep-body">
18927 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
18928 <details class="schema"><summary>Response schema</summary>
18929<div class="schema-block">// Running
18930{ "state": "running", "elapsed_secs": number }
18931
18932// Complete
18933{ "state": "complete", "run_id": string }
18934
18935// Failed
18936{ "state": "failed", "message": string }</div></details>
18937 <p class="curl-heading">Example</p>
18938 <div class="curl-wrap">
18939 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18940 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
18941 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
18942 </div>
18943 </div>
18944 </div>
18945
18946 <div class="ep-card">
18947 <div class="ep-header">
18948 <span class="method get">GET</span>
18949 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
18950 <span class="auth-badge protected">Protected</span>
18951 <span class="ep-desc">Poll PDF generation readiness</span>
18952 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18953 </div>
18954 <div class="ep-body">
18955 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
18956 <details class="schema"><summary>Response schema</summary>
18957<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
18958 <p class="curl-heading">Example</p>
18959 <div class="curl-wrap">
18960 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18961 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
18962 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
18963 </div>
18964 </div>
18965 </div>
18966
18967 <div class="ep-card">
18968 <div class="ep-header">
18969 <span class="method post">POST</span>
18970 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
18971 <span class="auth-badge protected">Protected</span>
18972 <span class="ep-desc">Cancel a running scan</span>
18973 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18974 </div>
18975 <div class="ep-body">
18976 <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>
18977 <p class="curl-heading">Example</p>
18978 <div class="curl-wrap">
18979 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
18980 -H "Authorization: Bearer $SLOC_API_KEY" \
18981 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
18982 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
18983 </div>
18984 </div>
18985 </div>
18986 </div>
18987
18988 <!-- Scan Profiles -->
18989 <div class="section">
18990 <h2 class="section-title">Scan Profiles</h2>
18991
18992 <div class="ep-card">
18993 <div class="ep-header">
18994 <span class="method get">GET</span>
18995 <span class="ep-path">/api/scan-profiles</span>
18996 <span class="auth-badge protected">Protected</span>
18997 <span class="ep-desc">List saved scan profiles</span>
18998 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18999 </div>
19000 <div class="ep-body">
19001 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19002 <details class="schema"><summary>Response schema</summary>
19003<div class="schema-block">{
19004 "profiles": [{
19005 "id": string, // UUID
19006 "name": string,
19007 "created_at": string, // ISO-8601
19008 "params": object
19009 }]
19010}</div></details>
19011 <p class="curl-heading">Example</p>
19012 <div class="curl-wrap">
19013 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19014 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19015 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19016 </div>
19017 </div>
19018 </div>
19019
19020 <div class="ep-card">
19021 <div class="ep-header">
19022 <span class="method post">POST</span>
19023 <span class="ep-path">/api/scan-profiles</span>
19024 <span class="auth-badge protected">Protected</span>
19025 <span class="ep-desc">Save a scan profile</span>
19026 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19027 </div>
19028 <div class="ep-body">
19029 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19030 <p class="params-heading">Request Body (application/json)</p>
19031 <table class="params">
19032 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19033 <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>
19034 <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>
19035 </table>
19036 <details class="schema"><summary>Response schema</summary>
19037<div class="schema-block">{ "ok": true }</div></details>
19038 <p class="curl-heading">Example</p>
19039 <div class="curl-wrap">
19040 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19041 -H "Authorization: Bearer $SLOC_API_KEY" \
19042 -H "Content-Type: application/json" \
19043 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19044 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19045 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19046 </div>
19047 </div>
19048 </div>
19049
19050 <div class="ep-card">
19051 <div class="ep-header">
19052 <span class="method delete">DELETE</span>
19053 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19054 <span class="auth-badge protected">Protected</span>
19055 <span class="ep-desc">Delete a scan profile</span>
19056 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19057 </div>
19058 <div class="ep-body">
19059 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19060 <p class="params-heading">Path Parameters</p>
19061 <table class="params">
19062 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19063 <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>
19064 </table>
19065 <details class="schema"><summary>Response schema</summary>
19066<div class="schema-block">{ "ok": true }</div></details>
19067 <p class="curl-heading">Example</p>
19068 <div class="curl-wrap">
19069 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19070 -H "Authorization: Bearer $SLOC_API_KEY" \
19071 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
19072 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19073 </div>
19074 </div>
19075 </div>
19076 </div>
19077
19078 <!-- Scheduled Scans -->
19079 <div class="section">
19080 <h2 class="section-title">Scheduled Scans</h2>
19081
19082 <div class="ep-card">
19083 <div class="ep-header">
19084 <span class="method get">GET</span>
19085 <span class="ep-path">/api/schedules</span>
19086 <span class="auth-badge protected">Protected</span>
19087 <span class="ep-desc">List configured schedules</span>
19088 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19089 </div>
19090 <div class="ep-body">
19091 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19092 <p class="curl-heading">Example</p>
19093 <div class="curl-wrap">
19094 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19095 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19096 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
19097 </div>
19098 </div>
19099 </div>
19100
19101 <div class="ep-card">
19102 <div class="ep-header">
19103 <span class="method post">POST</span>
19104 <span class="ep-path">/api/schedules</span>
19105 <span class="auth-badge protected">Protected</span>
19106 <span class="ep-desc">Create a schedule</span>
19107 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19108 </div>
19109 <div class="ep-body">
19110 <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>
19111 <p class="curl-heading">Example</p>
19112 <div class="curl-wrap">
19113 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19114 -H "Authorization: Bearer $SLOC_API_KEY" \
19115 -H "Content-Type: application/json" \
19116 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
19117 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19118 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
19119 </div>
19120 </div>
19121 </div>
19122
19123 <div class="ep-card">
19124 <div class="ep-header">
19125 <span class="method delete">DELETE</span>
19126 <span class="ep-path">/api/schedules</span>
19127 <span class="auth-badge protected">Protected</span>
19128 <span class="ep-desc">Delete a schedule</span>
19129 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19130 </div>
19131 <div class="ep-body">
19132 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19133 <p class="curl-heading">Example</p>
19134 <div class="curl-wrap">
19135 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19136 -H "Authorization: Bearer $SLOC_API_KEY" \
19137 -H "Content-Type: application/json" \
19138 -d '{"id":"<schedule_id>"}' \
19139 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19140 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19141 </div>
19142 </div>
19143 </div>
19144 </div>
19145
19146 <!-- Git Browser -->
19147 <div class="section">
19148 <h2 class="section-title">Git Browser</h2>
19149
19150 <div class="ep-card">
19151 <div class="ep-header">
19152 <span class="method get">GET</span>
19153 <span class="ep-path">/api/git/refs</span>
19154 <span class="auth-badge protected">Protected</span>
19155 <span class="ep-desc">List git refs for a repository</span>
19156 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19157 </div>
19158 <div class="ep-body">
19159 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19160 <p class="params-heading">Query Parameters</p>
19161 <table class="params">
19162 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19163 <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>
19164 </table>
19165 <p class="curl-heading">Example</p>
19166 <div class="curl-wrap">
19167 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19168 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19169 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19170 </div>
19171 </div>
19172 </div>
19173
19174 <div class="ep-card">
19175 <div class="ep-header">
19176 <span class="method get">GET</span>
19177 <span class="ep-path">/api/git/scan-ref</span>
19178 <span class="auth-badge protected">Protected</span>
19179 <span class="ep-desc">SLOC-scan a specific git ref</span>
19180 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19181 </div>
19182 <div class="ep-body">
19183 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19184 <p class="params-heading">Query Parameters</p>
19185 <table class="params">
19186 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19187 <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>
19188 <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>
19189 </table>
19190 <p class="curl-heading">Example</p>
19191 <div class="curl-wrap">
19192 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19193 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
19194 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19195 </div>
19196 </div>
19197 </div>
19198
19199 <div class="ep-card">
19200 <div class="ep-header">
19201 <span class="method get">GET</span>
19202 <span class="ep-path">/api/git/compare-refs</span>
19203 <span class="auth-badge protected">Protected</span>
19204 <span class="ep-desc">Compare SLOC across two git refs</span>
19205 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19206 </div>
19207 <div class="ep-body">
19208 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19209 <p class="params-heading">Query Parameters</p>
19210 <table class="params">
19211 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19212 <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>
19213 <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>
19214 <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>
19215 </table>
19216 <p class="curl-heading">Example</p>
19217 <div class="curl-wrap">
19218 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19219 "<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>
19220 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19221 </div>
19222 </div>
19223 </div>
19224 </div>
19225
19226 <!-- Webhooks -->
19227 <div class="section">
19228 <h2 class="section-title">Webhooks</h2>
19229 <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>
19230
19231 <div class="ep-card">
19232 <div class="ep-header">
19233 <span class="method post">POST</span>
19234 <span class="ep-path">/webhooks/github</span>
19235 <span class="auth-badge hmac">HMAC</span>
19236 <span class="ep-desc">GitHub push event receiver</span>
19237 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19238 </div>
19239 <div class="ep-body">
19240 <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>
19241 <p class="params-heading">Required Headers</p>
19242 <table class="params">
19243 <tr><th>Header</th><th>Value</th></tr>
19244 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19245 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19246 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19247 </table>
19248 </div>
19249 </div>
19250
19251 <div class="ep-card">
19252 <div class="ep-header">
19253 <span class="method post">POST</span>
19254 <span class="ep-path">/webhooks/gitlab</span>
19255 <span class="auth-badge hmac">HMAC</span>
19256 <span class="ep-desc">GitLab push event receiver</span>
19257 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19258 </div>
19259 <div class="ep-body">
19260 <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>
19261 <p class="params-heading">Required Headers</p>
19262 <table class="params">
19263 <tr><th>Header</th><th>Value</th></tr>
19264 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19265 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19266 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19267 </table>
19268 </div>
19269 </div>
19270
19271 <div class="ep-card">
19272 <div class="ep-header">
19273 <span class="method post">POST</span>
19274 <span class="ep-path">/webhooks/bitbucket</span>
19275 <span class="auth-badge hmac">HMAC</span>
19276 <span class="ep-desc">Bitbucket push event receiver</span>
19277 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19278 </div>
19279 <div class="ep-body">
19280 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19281 <p class="params-heading">Required Headers</p>
19282 <table class="params">
19283 <tr><th>Header</th><th>Value</th></tr>
19284 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19285 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19286 </table>
19287 </div>
19288 </div>
19289 </div>
19290
19291 <!-- Config -->
19292 <div class="section">
19293 <h2 class="section-title">Config Import / Export</h2>
19294
19295 <div class="ep-card">
19296 <div class="ep-header">
19297 <span class="method get">GET</span>
19298 <span class="ep-path">/export-config</span>
19299 <span class="auth-badge protected">Protected</span>
19300 <span class="ep-desc">Export server configuration as JSON</span>
19301 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19302 </div>
19303 <div class="ep-body">
19304 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19305 <p class="curl-heading">Example</p>
19306 <div class="curl-wrap">
19307 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19308 -o config.json \
19309 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19310 <button class="curl-copy-btn" data-target="c-export">Copy</button>
19311 </div>
19312 </div>
19313 </div>
19314
19315 <div class="ep-card">
19316 <div class="ep-header">
19317 <span class="method post">POST</span>
19318 <span class="ep-path">/import-config</span>
19319 <span class="auth-badge protected">Protected</span>
19320 <span class="ep-desc">Import server configuration</span>
19321 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19322 </div>
19323 <div class="ep-body">
19324 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19325 <p class="curl-heading">Example</p>
19326 <div class="curl-wrap">
19327 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19328 -H "Authorization: Bearer $SLOC_API_KEY" \
19329 -H "Content-Type: application/json" \
19330 -d @config.json \
19331 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19332 <button class="curl-copy-btn" data-target="c-import">Copy</button>
19333 </div>
19334 </div>
19335 </div>
19336 </div>
19337
19338 <!-- CI Ingest -->
19339 <div class="section">
19340 <h2 class="section-title">CI Ingest</h2>
19341
19342 <div class="ep-card">
19343 <div class="ep-header">
19344 <span class="method post">POST</span>
19345 <span class="ep-path">/api/ingest</span>
19346 <span class="auth-badge protected">Protected</span>
19347 <span class="ep-desc">Push a pre-computed scan result from CI</span>
19348 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19349 </div>
19350 <div class="ep-body">
19351 <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>
19352 <p class="params-heading">Query Parameters</p>
19353 <table class="params">
19354 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19355 <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>
19356 </table>
19357 <p class="params-heading">Request Body (application/json)</p>
19358 <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>
19359 <details class="schema"><summary>Response schema</summary>
19360<div class="schema-block">// 201 Created
19361{
19362 "run_id": string, // UUID of the ingested run
19363 "view_url": string // relative URL to the report page
19364}</div></details>
19365 <p class="curl-heading">Example</p>
19366 <div class="curl-wrap">
19367 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19368 -H "Authorization: Bearer $SLOC_API_KEY" \
19369 -H "Content-Type: application/json" \
19370 -d @result.json \
19371 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19372 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19373 </div>
19374 </div>
19375 </div>
19376 </div>
19377
19378 <!-- Artifact Download -->
19379 <div class="section">
19380 <h2 class="section-title">Artifact Download</h2>
19381
19382 <div class="ep-card">
19383 <div class="ep-header">
19384 <span class="method get">GET</span>
19385 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19386 <span class="auth-badge protected">Protected</span>
19387 <span class="ep-desc">Download or view a scan artifact</span>
19388 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19389 </div>
19390 <div class="ep-body">
19391 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19392 <p class="params-heading">Path Parameters</p>
19393 <table class="params">
19394 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19395 <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>
19396 <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>
19397 </table>
19398 <p class="params-heading">Query Parameters</p>
19399 <table class="params">
19400 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19401 <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>
19402 </table>
19403 <p class="curl-heading">Example — download JSON result</p>
19404 <div class="curl-wrap">
19405 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19406 -o result.json \
19407 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
19408 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19409 </div>
19410 </div>
19411 </div>
19412 </div>
19413
19414 <!-- Embed Widget -->
19415 <div class="section">
19416 <h2 class="section-title">Embed Widget</h2>
19417
19418 <div class="ep-card">
19419 <div class="ep-header">
19420 <span class="method get">GET</span>
19421 <span class="ep-path">/embed/summary</span>
19422 <span class="auth-badge protected">Protected</span>
19423 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19424 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19425 </div>
19426 <div class="ep-body">
19427 <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>
19428 <p class="params-heading">Query Parameters</p>
19429 <table class="params">
19430 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19431 <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>
19432 <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>
19433 </table>
19434 <p class="curl-heading">Example</p>
19435 <div class="curl-wrap">
19436 <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"
19437 width="460" height="260" style="border:none"></iframe></pre>
19438 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19439 </div>
19440 </div>
19441 </div>
19442 </div>
19443
19444 <!-- Confluence Integration -->
19445 <div class="section">
19446 <h2 class="section-title">Confluence Integration</h2>
19447
19448 <div class="ep-card">
19449 <div class="ep-header">
19450 <span class="method get">GET</span>
19451 <span class="ep-path">/api/confluence/config</span>
19452 <span class="auth-badge protected">Protected</span>
19453 <span class="ep-desc">Get current Confluence configuration</span>
19454 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19455 </div>
19456 <div class="ep-body">
19457 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19458 <details class="schema"><summary>Response schema</summary>
19459<div class="schema-block">{
19460 "configured": boolean,
19461 "tier": "cloud" | "server",
19462 "base_url": string,
19463 "username": string,
19464 "api_token_set": boolean,
19465 "space_key": string,
19466 "parent_page_id": string | null,
19467 "schedule_auto_post": { "<schedule_id>": boolean }
19468}</div></details>
19469 <p class="curl-heading">Example</p>
19470 <div class="curl-wrap">
19471 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19472 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19473 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19474 </div>
19475 </div>
19476 </div>
19477
19478 <div class="ep-card">
19479 <div class="ep-header">
19480 <span class="method post">POST</span>
19481 <span class="ep-path">/api/confluence/config</span>
19482 <span class="auth-badge protected">Protected</span>
19483 <span class="ep-desc">Save Confluence configuration</span>
19484 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19485 </div>
19486 <div class="ep-body">
19487 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19488 <p class="params-heading">Request Body (application/json)</p>
19489 <table class="params">
19490 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19491 <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>
19492 <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>
19493 <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>
19494 <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>
19495 <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>
19496 <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>
19497 <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>
19498 </table>
19499 <details class="schema"><summary>Response schema</summary>
19500<div class="schema-block">{ "ok": true }</div></details>
19501 <p class="curl-heading">Example</p>
19502 <div class="curl-wrap">
19503 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19504 -H "Authorization: Bearer $SLOC_API_KEY" \
19505 -H "Content-Type: application/json" \
19506 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19507 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19508 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
19509 </div>
19510 </div>
19511 </div>
19512
19513 <div class="ep-card">
19514 <div class="ep-header">
19515 <span class="method post">POST</span>
19516 <span class="ep-path">/api/confluence/test</span>
19517 <span class="auth-badge protected">Protected</span>
19518 <span class="ep-desc">Test Confluence connection</span>
19519 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19520 </div>
19521 <div class="ep-body">
19522 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19523 <details class="schema"><summary>Response schema</summary>
19524<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19525 <p class="curl-heading">Example</p>
19526 <div class="curl-wrap">
19527 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19528 -H "Authorization: Bearer $SLOC_API_KEY" \
19529 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19530 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19531 </div>
19532 </div>
19533 </div>
19534
19535 <div class="ep-card">
19536 <div class="ep-header">
19537 <span class="method post">POST</span>
19538 <span class="ep-path">/api/confluence/post</span>
19539 <span class="auth-badge protected">Protected</span>
19540 <span class="ep-desc">Publish a scan report to Confluence</span>
19541 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19542 </div>
19543 <div class="ep-body">
19544 <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>
19545 <p class="params-heading">Request Body (application/json)</p>
19546 <table class="params">
19547 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19548 <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>
19549 <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>
19550 <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>
19551 </table>
19552 <details class="schema"><summary>Response schema</summary>
19553<div class="schema-block">// 200 OK
19554{ "ok": true, "page_id": string }
19555
19556// 400 / 502 on error
19557{ "ok": false, "error": string }</div></details>
19558 <p class="curl-heading">Example</p>
19559 <div class="curl-wrap">
19560 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19561 -H "Authorization: Bearer $SLOC_API_KEY" \
19562 -H "Content-Type: application/json" \
19563 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
19564 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19565 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19566 </div>
19567 </div>
19568 </div>
19569
19570 <div class="ep-card">
19571 <div class="ep-header">
19572 <span class="method get">GET</span>
19573 <span class="ep-path">/api/confluence/wiki-markup</span>
19574 <span class="auth-badge protected">Protected</span>
19575 <span class="ep-desc">Get Confluence wiki markup for a run</span>
19576 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19577 </div>
19578 <div class="ep-body">
19579 <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>
19580 <p class="params-heading">Query Parameters</p>
19581 <table class="params">
19582 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19583 <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>
19584 </table>
19585 <p class="curl-heading">Example</p>
19586 <div class="curl-wrap">
19587 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19588 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
19589 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19590 </div>
19591 </div>
19592 </div>
19593 </div>
19594
19595 <!-- Authentication -->
19596 <div class="section">
19597 <h2 class="section-title">Authentication</h2>
19598 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19599
19600 <div class="ep-card">
19601 <div class="ep-header">
19602 <span class="method get">GET</span>
19603 <span class="ep-path">/auth/login</span>
19604 <span class="auth-badge public">Public</span>
19605 <span class="ep-desc">Login page</span>
19606 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19607 </div>
19608 <div class="ep-body">
19609 <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>
19610 <p class="params-heading">Query Parameters</p>
19611 <table class="params">
19612 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19613 <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>
19614 <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>
19615 </table>
19616 </div>
19617 </div>
19618
19619 <div class="ep-card">
19620 <div class="ep-header">
19621 <span class="method post">POST</span>
19622 <span class="ep-path">/auth/login</span>
19623 <span class="auth-badge public">Public</span>
19624 <span class="ep-desc">Submit credentials and get a session cookie</span>
19625 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19626 </div>
19627 <div class="ep-body">
19628 <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>
19629 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19630 <table class="params">
19631 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19632 <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>
19633 <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>
19634 </table>
19635 <p class="curl-heading">Example</p>
19636 <div class="curl-wrap">
19637 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19638 -d "key=$SLOC_API_KEY&next=/" \
19639 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19640 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19641 </div>
19642 </div>
19643 </div>
19644 </div>
19645
19646 <!-- Coverage Suggestion -->
19647 <div class="section">
19648 <h2 class="section-title">Coverage Suggestion</h2>
19649
19650 <div class="ep-card">
19651 <div class="ep-header">
19652 <span class="method get">GET</span>
19653 <span class="ep-path">/api/suggest-coverage</span>
19654 <span class="auth-badge protected">Protected</span>
19655 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19656 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19657 </div>
19658 <div class="ep-body">
19659 <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>
19660 <p class="params-heading">Query Parameters</p>
19661 <table class="params">
19662 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19663 <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>
19664 </table>
19665 <details class="schema"><summary>Response schema</summary>
19666<div class="schema-block">{
19667 "found": string | null, // absolute path to the coverage file, if detected
19668 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19669 "hint": string | null // shell command to generate coverage if not found
19670}</div></details>
19671 <p class="curl-heading">Example</p>
19672 <div class="curl-wrap">
19673 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19674 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19675 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19676 </div>
19677 </div>
19678 </div>
19679 </div>
19680
19681 </div>
19682
19683 <footer class="site-footer">
19684 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
19685 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19686 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19687 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19688 · <a href="/api-docs" rel="noopener">REST API</a>
19689 </footer>
19690
19691 <script nonce="{{ csp_nonce }}">
19692 (function () {
19693 var base = window.location.origin;
19694 document.getElementById('base-url').textContent = base;
19695 document.querySelectorAll('.base-url-slot').forEach(function (el) {
19696 el.textContent = base;
19697 });
19698
19699 document.querySelectorAll('.ep-header').forEach(function (hdr) {
19700 hdr.addEventListener('click', function () {
19701 hdr.closest('.ep-card').classList.toggle('open');
19702 });
19703 });
19704
19705 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19706 btn.addEventListener('click', function () {
19707 var targetId = btn.dataset.target;
19708 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19709 if (!pre) return;
19710 navigator.clipboard.writeText(pre.textContent).then(function () {
19711 btn.textContent = 'Copied!';
19712 btn.classList.add('copied');
19713 setTimeout(function () {
19714 btn.textContent = 'Copy';
19715 btn.classList.remove('copied');
19716 }, 2000);
19717 });
19718 });
19719 });
19720
19721 var storageKey = 'oxide-sloc-theme';
19722 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19723 var themeBtn = document.getElementById('theme-toggle');
19724 if (themeBtn) {
19725 themeBtn.addEventListener('click', function () {
19726 var dark = document.body.classList.toggle('dark-theme');
19727 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19728 });
19729 }
19730 (function() {
19731 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'}];
19732 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);});}
19733 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19734 var btn=document.getElementById('settings-btn');if(!btn)return;
19735 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19736 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>';
19737 document.body.appendChild(m);
19738 var g=document.getElementById('scheme-grid');
19739 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);});
19740 var cl=document.getElementById('settings-close');
19741 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);
19742 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');});
19743 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19744 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19745 })();
19746 (function randomizeWatermarks() {
19747 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19748 if (!wms.length) return;
19749 var placed = [];
19750 function tooClose(top, left) {
19751 for (var i = 0; i < placed.length; i++) {
19752 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19753 if (dt < 16 && dl < 12) return true;
19754 }
19755 return false;
19756 }
19757 function pick(leftBand) {
19758 for (var attempt = 0; attempt < 50; attempt++) {
19759 var top = Math.random() * 88 + 2;
19760 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19761 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19762 }
19763 var top = Math.random() * 88 + 2;
19764 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19765 placed.push([top, left]); return [top, left];
19766 }
19767 var half = Math.floor(wms.length / 2);
19768 wms.forEach(function (img, i) {
19769 var pos = pick(i < half);
19770 var size = Math.floor(Math.random() * 100 + 120);
19771 var rot = (Math.random() * 360).toFixed(1);
19772 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19773 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;
19774 });
19775 })();
19776 (function spawnCodeParticles() {
19777 var container = document.getElementById('code-particles');
19778 if (!container) return;
19779 var snippets = [
19780 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19781 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19782 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19783 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19784 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19785 ];
19786 var count = 38;
19787 for (var i = 0; i < count; i++) {
19788 (function(idx) {
19789 var el = document.createElement('span');
19790 el.className = 'code-particle';
19791 el.textContent = snippets[idx % snippets.length];
19792 var left = Math.random() * 94 + 2;
19793 var top = Math.random() * 88 + 6;
19794 var dur = (Math.random() * 10 + 9).toFixed(1);
19795 var delay = (Math.random() * 18).toFixed(1);
19796 var rot = (Math.random() * 26 - 13).toFixed(1);
19797 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19798 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
19799 container.appendChild(el);
19800 })(i);
19801 }
19802 })();
19803 }());
19804 </script>
19805</body>
19806</html>
19807"##,
19808 ext = "html"
19809)]
19810struct ApiDocsTemplate {
19811 has_api_key: bool,
19812 csp_nonce: String,
19813 version: &'static str,
19814}