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 confluence;
26pub(crate) mod git_browser;
27pub(crate) mod git_webhook;
28pub(crate) mod integrations;
29
30use std::{
31 collections::{HashMap, VecDeque},
32 fmt::Write,
33 fs,
34 net::{IpAddr, SocketAddr},
35 path::{Path, PathBuf},
36 process::Stdio,
37 sync::Arc,
38 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
39};
40
41use anyhow::{Context, Result};
42use askama::Template;
43use axum::{
44 body::Body,
45 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
46 http::{header, HeaderValue, Request, StatusCode},
47 middleware::{self, Next},
48 response::{Html, IntoResponse, Response},
49 routing::{get, post},
50 Json, Router,
51};
52use serde::{Deserialize, Serialize};
53use tokio::sync::Mutex;
54use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
55
56use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
57use sloc_git::ScheduleStore;
58
59#[derive(Clone)]
60struct CspNonce(String);
61
62static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
63
64use sloc_core::{
65 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
66 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
67};
68use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
69const MAX_CONCURRENT_ANALYSES: usize = 4;
70
71#[cfg(all(target_os = "windows", feature = "native-dialog"))]
79#[allow(clippy::upper_case_acronyms)]
80mod win_dialog_focus {
81 use std::mem::size_of;
82
83 type HWND = *mut core::ffi::c_void;
84 type DWORD = u32;
85 type UINT = u32;
86 type BOOL = i32;
87
88 #[repr(C)]
92 #[allow(non_snake_case)]
93 struct FLASHWINFO {
94 cbSize: UINT,
95 hwnd: HWND,
96 dwFlags: DWORD,
97 uCount: UINT,
98 dwTimeout: DWORD,
99 }
100
101 const FLASHW_ALL: DWORD = 0x3;
102 const FLASHW_TIMERNOFG: DWORD = 0xC;
103
104 #[link(name = "user32")]
105 extern "system" {
106 fn GetForegroundWindow() -> HWND;
107 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
108 fn BringWindowToTop(hWnd: HWND) -> BOOL;
109 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
110 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
111 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
112 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
113 }
114
115 #[link(name = "kernel32")]
116 extern "system" {
117 fn GetCurrentThreadId() -> DWORD;
118 }
119
120 pub fn attach_to_foreground() -> DWORD {
125 unsafe {
126 let fg_hwnd = GetForegroundWindow();
127 if fg_hwnd.is_null() {
128 return 0;
129 }
130 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
131 let my_tid = GetCurrentThreadId();
132 if fg_tid == my_tid {
133 return 0;
134 }
135 AttachThreadInput(my_tid, fg_tid, 1);
136 fg_tid
137 }
138 }
139
140 pub fn detach_from_foreground(fg_tid: DWORD) {
142 if fg_tid == 0 {
143 return;
144 }
145 unsafe {
146 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
147 }
148 }
149
150 pub fn flash_dialog_when_ready(title: String) {
154 std::thread::spawn(move || {
155 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
156 for _ in 0..40 {
157 std::thread::sleep(std::time::Duration::from_millis(80));
158 unsafe {
159 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
160 if !hwnd.is_null() {
161 SetForegroundWindow(hwnd);
162 BringWindowToTop(hwnd);
163 #[allow(non_snake_case)]
164 FlashWindowEx(&FLASHWINFO {
165 #[allow(clippy::cast_possible_truncation)]
168 cbSize: size_of::<FLASHWINFO>() as UINT,
169 hwnd,
170 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
171 uCount: 3,
172 dwTimeout: 0,
173 });
174 break;
175 }
176 }
177 }
178 });
179 }
180}
181
182struct IpRateLimiter {
185 window: Duration,
186 max_requests: usize,
187 auth_lockout_threshold: u32,
188 auth_lockout_window: Duration,
189 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
190 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
191}
192
193impl IpRateLimiter {
194 fn new(
195 window: Duration,
196 max_requests: usize,
197 auth_lockout_threshold: u32,
198 auth_lockout_window: Duration,
199 ) -> Self {
200 Self {
201 window,
202 max_requests,
203 auth_lockout_threshold,
204 auth_lockout_window,
205 state: std::sync::Mutex::new(HashMap::new()),
206 auth_failures: std::sync::Mutex::new(HashMap::new()),
207 }
208 }
209
210 #[allow(clippy::significant_drop_tightening)]
213 fn is_allowed(&self, ip: IpAddr) -> bool {
214 let now = Instant::now();
215 let cutoff = now.checked_sub(self.window).unwrap_or(now);
216 let mut state = self
217 .state
218 .lock()
219 .unwrap_or_else(std::sync::PoisonError::into_inner);
220 if state.len() > 10_000 {
221 state.retain(|_, bucket| {
222 while bucket.front().is_some_and(|t| *t <= cutoff) {
223 bucket.pop_front();
224 }
225 !bucket.is_empty()
226 });
227 }
228 let bucket = state.entry(ip).or_default();
229 while bucket.front().is_some_and(|t| *t <= cutoff) {
230 bucket.pop_front();
231 }
232 if bucket.len() >= self.max_requests {
233 false
234 } else {
235 bucket.push_back(now);
236 true
237 }
238 }
239
240 fn record_auth_failure(&self, ip: IpAddr) {
241 let now = Instant::now();
242 let mut map = self
243 .auth_failures
244 .lock()
245 .unwrap_or_else(std::sync::PoisonError::into_inner);
246 map.entry(ip)
247 .and_modify(|e| {
248 e.0 += 1;
249 e.1 = now;
250 })
251 .or_insert_with(|| (1, now));
252 }
253
254 fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
255 let mut map = self
256 .auth_failures
257 .lock()
258 .unwrap_or_else(std::sync::PoisonError::into_inner);
259 let expired = map
260 .get(&ip)
261 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
262 if expired {
263 map.remove(&ip);
264 return false;
265 }
266 map.get(&ip)
267 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
268 }
269
270 fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
271 let map = self
272 .auth_failures
273 .lock()
274 .unwrap_or_else(std::sync::PoisonError::into_inner);
275 map.get(&ip).map_or(0, |e| {
276 self.auth_lockout_window
277 .checked_sub(e.1.elapsed())
278 .map_or(0, |r| r.as_secs())
279 })
280 }
281
282 fn spawn_pruning_task(limiter: Arc<Self>) {
283 tokio::spawn(async move {
284 let mut interval = tokio::time::interval(Duration::from_mins(1));
285 interval.tick().await; loop {
287 interval.tick().await;
288 let now = Instant::now();
289 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
290 {
291 let mut state = limiter
292 .state
293 .lock()
294 .unwrap_or_else(std::sync::PoisonError::into_inner);
295 state.retain(|_, bucket| {
296 while bucket.front().is_some_and(|t| *t <= cutoff) {
297 bucket.pop_front();
298 }
299 !bucket.is_empty()
300 });
301 }
302 {
303 let mut auth = limiter
304 .auth_failures
305 .lock()
306 .unwrap_or_else(std::sync::PoisonError::into_inner);
307 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
308 }
309 }
310 });
311 }
312}
313
314#[derive(Clone, Debug, Default)]
316struct RunResultContext {
317 prev_entry: Option<RegistryEntry>,
318 prev_scan_count: usize,
319 project_path: String,
320}
321
322#[derive(Clone)]
324enum AsyncRunState {
325 Running {
326 started_at: std::time::Instant,
327 cancel_token: Arc<std::sync::atomic::AtomicBool>,
328 },
329 Complete {
331 run_id: String,
332 },
333 Failed {
334 message: String,
335 },
336 Cancelled,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
342struct ScanProfile {
343 id: String,
344 name: String,
345 created_at: String,
346 params: serde_json::Value,
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351struct ScanProfileStore {
352 profiles: Vec<ScanProfile>,
353}
354
355impl ScanProfileStore {
356 fn load(path: &std::path::Path) -> Self {
357 fs::read_to_string(path)
358 .ok()
359 .and_then(|s| serde_json::from_str(&s).ok())
360 .unwrap_or_default()
361 }
362
363 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
364 if let Some(parent) = path.parent() {
365 fs::create_dir_all(parent)?;
366 }
367 let json = serde_json::to_string_pretty(self)?;
368 fs::write(path, json)?;
369 Ok(())
370 }
371}
372
373#[derive(Clone)]
374struct AppState {
375 base_config: AppConfig,
376 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
377 async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
378 registry: Arc<Mutex<ScanRegistry>>,
379 registry_path: PathBuf,
380 analyze_semaphore: Arc<tokio::sync::Semaphore>,
381 server_mode: bool,
382 tls_enabled: bool,
383 api_keys: Vec<secrecy::Secret<String>>,
384 rate_limiter: Arc<IpRateLimiter>,
385 trust_proxy: bool,
386 git_clones_dir: PathBuf,
388 schedules: Arc<Mutex<ScheduleStore>>,
390 schedules_path: PathBuf,
391 scan_profiles: Arc<Mutex<ScanProfileStore>>,
393 scan_profiles_path: PathBuf,
394 sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
395 confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
397 confluence_path: PathBuf,
398 watched_dirs: Arc<Mutex<WatchedDirsStore>>,
400 watched_dirs_path: PathBuf,
401}
402
403type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
404
405#[derive(Clone, Debug)]
408pub(crate) struct RunArtifacts {
409 output_dir: PathBuf,
410 html_path: Option<PathBuf>,
411 pdf_path: Option<PathBuf>,
412 json_path: Option<PathBuf>,
413 scan_config_path: Option<PathBuf>,
414 report_title: String,
415 result_context: RunResultContext,
416}
417
418#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
420 let protected = Router::new()
422 .route("/", get(splash))
423 .route("/scan-setup", get(scan_setup_handler))
424 .route("/scan", get(index))
425 .route("/analyze", post(analyze_handler))
426 .route("/preview", get(preview_handler))
427 .route("/api/suggest-coverage", get(api_suggest_coverage))
428 .route("/pick-directory", get(pick_directory_handler))
429 .route("/open-path", get(open_path_handler))
430 .route("/pick-file", get(pick_file_handler))
431 .route("/locate-report", post(locate_report_handler))
432 .route("/locate-reports-dir", post(locate_reports_dir_handler))
433 .route("/relocate-scan", post(relocate_scan_handler))
434 .route("/watched-dirs/add", post(add_watched_dir_handler))
435 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
436 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
437 .route("/view-reports", get(history_handler))
438 .route("/compare-scans", get(compare_select_handler))
439 .route("/compare", get(compare_handler))
440 .route("/images/{folder}/{file}", get(image_handler))
441 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
442 .route("/api/metrics/latest", get(api_metrics_latest_handler))
443 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
444 .route("/api/metrics/history", get(api_metrics_history_handler))
445 .route(
446 "/api/metrics/submodules",
447 get(api_metrics_submodules_handler),
448 )
449 .route("/api/ingest", post(api_ingest_handler))
450 .route("/api/project-history", get(project_history_handler))
451 .route("/trend-reports", get(trend_report_handler))
452 .route("/test-metrics", get(test_metrics_handler))
453 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
454 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
455 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
456 .route("/runs/result/{run_id}", get(async_run_result_handler))
457 .route("/embed/summary", get(embed_handler))
458 .route("/git-browser", get(git_browser::git_browser_handler))
460 .route("/api/git/refs", get(git_browser::api_list_refs))
461 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
462 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
463 .route("/export-config", get(export_config_handler))
465 .route("/import-config", post(import_config_handler))
466 .route("/api/scan-profiles", get(api_list_scan_profiles))
468 .route("/api/scan-profiles", post(api_save_scan_profile))
469 .route(
470 "/api/scan-profiles/{id}",
471 axum::routing::delete(api_delete_scan_profile),
472 )
473 .route("/integrations", get(integrations::integrations_handler))
475 .route(
476 "/webhook-setup",
477 get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
478 )
479 .route(
480 "/confluence-setup",
481 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
482 )
483 .route("/api/schedules", get(git_webhook::api_list_schedules))
484 .route("/api/schedules", post(git_webhook::api_create_schedule))
485 .route(
486 "/api/schedules",
487 axum::routing::delete(git_webhook::api_delete_schedule),
488 )
489 .route(
490 "/api/confluence/config",
491 get(confluence::api_get_confluence_config),
492 )
493 .route(
494 "/api/confluence/config",
495 post(confluence::api_save_confluence_config),
496 )
497 .route(
498 "/api/confluence/test",
499 post(confluence::api_test_confluence),
500 )
501 .route(
502 "/api/confluence/post",
503 post(confluence::api_post_to_confluence),
504 )
505 .route(
506 "/api/confluence/wiki-markup",
507 get(confluence::api_wiki_markup),
508 )
509 .route("/api-docs", get(api_docs_handler))
511 .route_layer(middleware::from_fn_with_state(
512 state.clone(),
513 require_api_key,
514 ));
515
516 protected
517 .route("/healthz", get(healthz))
518 .route("/badge/{metric}", get(badge_handler))
519 .route("/static/chart.js", get(chart_js_handler))
520 .route("/auth/login", get(auth_login_get))
521 .route("/auth/login", post(auth_login_post))
522 .route("/webhooks/github", post(git_webhook::handle_github_webhook))
524 .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
525 .route(
526 "/webhooks/bitbucket",
527 post(git_webhook::handle_bitbucket_webhook),
528 )
529 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
530 .layer(middleware::from_fn_with_state(
531 state.clone(),
532 add_security_headers,
533 ))
534 .layer(build_cors_layer(state.server_mode))
535 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
536 .with_state(state)
537}
538
539pub fn make_test_router() -> Router {
541 let tmp = std::env::temp_dir().join("sloc_test");
542 let state = AppState {
543 base_config: AppConfig::default(),
544 artifacts: Arc::new(Mutex::new(HashMap::new())),
545 async_runs: Arc::new(Mutex::new(HashMap::new())),
546 registry: Arc::new(Mutex::new(ScanRegistry::default())),
547 registry_path: tmp.join("registry.json"),
548 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
549 server_mode: false,
550 tls_enabled: false,
551 api_keys: vec![],
552 rate_limiter: Arc::new(IpRateLimiter::new(
553 Duration::from_mins(1),
554 600,
555 10,
556 Duration::from_hours(1),
557 )),
558 trust_proxy: false,
559 git_clones_dir: tmp.join("git-clones"),
560 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
561 schedules_path: tmp.join("schedules.json"),
562 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
563 scan_profiles_path: tmp.join("scan_profiles.json"),
564 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
565 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
566 confluence_path: tmp.join("confluence_config.json"),
567 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
568 watched_dirs_path: tmp.join("watched_dirs.json"),
569 };
570 build_router(state)
571}
572
573#[allow(clippy::too_many_lines)]
584pub async fn serve(config: AppConfig) -> Result<()> {
585 let bind_address = config.web.bind_address.clone();
587 let server_mode = config.web.server_mode;
588 let output_root = resolve_output_root(None);
589 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
591 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
592 let mut registry = ScanRegistry::load(®istry_path);
593 registry.prune_stale();
594 let _ = registry.save(®istry_path);
595
596 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
597 .or_else(|_| std::env::var("SLOC_API_KEY"))
598 .unwrap_or_default()
599 .split(',')
600 .map(str::trim)
601 .filter(|s| !s.is_empty())
602 .map(|s| secrecy::Secret::new(s.to_owned()))
603 .collect();
604 if server_mode && api_keys.is_empty() {
605 println!(
606 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
607 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
608 );
609 }
610
611 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
612 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
613 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
614 if server_mode && !tls_enabled {
615 println!(
616 "WARNING: TLS is not configured. Traffic is cleartext. \
617 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
618 or terminate TLS at a reverse proxy (nginx, caddy)."
619 );
620 }
621 if server_mode {
622 println!(
623 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
624 to restrict cross-origin access (comma-separated)."
625 );
626 }
627 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
628 if trust_proxy {
629 println!(
630 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
631 Only set this when oxide-sloc is behind a trusted reverse proxy."
632 );
633 }
634
635 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
636 .ok()
637 .and_then(|v| v.parse::<u32>().ok())
638 .unwrap_or(10);
639 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
640 .ok()
641 .and_then(|v| v.parse::<u64>().ok())
642 .unwrap_or(3600);
643 let rate_limiter = Arc::new(IpRateLimiter::new(
645 Duration::from_mins(1),
646 600,
647 auth_lockout_threshold,
648 Duration::from_secs(auth_lockout_secs),
649 ));
650 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
651
652 let git_clones_dir = resolve_git_clones_dir(&output_root);
653 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
654 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
655 let schedules = ScheduleStore::load(&schedules_path);
656 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
657 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
658 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
659 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
660 |_| output_root.join("confluence_config.json"),
661 PathBuf::from,
662 );
663 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
664 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
665 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
666 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
667
668 let state = AppState {
669 base_config: config,
670 artifacts: Arc::new(Mutex::new(HashMap::new())),
671 async_runs: Arc::new(Mutex::new(HashMap::new())),
672 registry: Arc::new(Mutex::new(registry)),
673 registry_path,
674 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
675 server_mode,
676 tls_enabled,
677 api_keys,
678 rate_limiter,
679 trust_proxy,
680 git_clones_dir,
681 schedules: Arc::new(Mutex::new(schedules)),
682 schedules_path,
683 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
684 scan_profiles_path,
685 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
686 confluence: Arc::new(Mutex::new(confluence)),
687 confluence_path,
688 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
689 watched_dirs_path,
690 };
691
692 restart_poll_schedules(&state).await;
693
694 let app = build_router(state.clone());
695
696 let preferred: SocketAddr = bind_address
701 .parse()
702 .with_context(|| format!("invalid bind address: {bind_address}"))?;
703 let (listener, addr) = {
704 let candidates = (0u16..=9).map(|offset| {
705 let mut a = preferred;
706 a.set_port(preferred.port().saturating_add(offset));
707 a
708 });
709 let mut found = None;
710 for candidate in candidates {
711 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
712 found = Some((l, candidate));
713 break;
714 }
715 }
716 found.ok_or_else(|| {
717 anyhow::anyhow!(
718 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
719 bind_address,
720 preferred.port(),
721 preferred.port().saturating_add(9)
722 )
723 })?
724 };
725 if addr != preferred {
726 eprintln!(
727 "NOTE: port {} is blocked by a system socket (Windows zombie); \
728 using {} instead.",
729 preferred.port(),
730 addr.port()
731 );
732 }
733
734 if tls_enabled {
735 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
736 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
737 let tls_config = build_tls_config(&cert_path, &key_path)
738 .context("failed to load TLS certificate/key")?;
739 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
740
741 let url = format!("https://{addr}/");
742 println!("OxideSLOC server running at {url} (TLS)");
743 println!("Use Ctrl+C to stop.");
744
745 return serve_tls(listener, app, acceptor, server_mode).await;
746 }
747
748 let url = format!("http://{addr}/");
749 log_startup_url(&url, server_mode);
750
751 axum::serve(
752 listener,
753 app.into_make_service_with_connect_info::<SocketAddr>(),
754 )
755 .with_graceful_shutdown(shutdown_signal(server_mode))
756 .await
757 .context("web server terminated unexpectedly")
758}
759
760fn primary_lan_ip() -> Option<String> {
764 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
765 socket.connect("8.8.8.8:80").ok()?;
766 let addr = socket.local_addr().ok()?;
767 let ip = addr.ip();
768 if ip.is_loopback() {
769 return None;
770 }
771 Some(ip.to_string())
772}
773
774fn log_startup_url(url: &str, server_mode: bool) {
776 if server_mode {
777 println!("OxideSLOC server running at {url}");
778 println!("Use Ctrl+C to stop.");
779 } else {
780 println!("OxideSLOC local web UI running at {url}");
781 println!("Press Ctrl+C to stop the server.");
782 let open_url = url.to_owned();
783 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
784 }
785}
786
787fn open_browser_tab(url: &str) {
789 #[cfg(target_os = "windows")]
790 let _ = std::process::Command::new("cmd")
791 .args(["/c", "start", "", url])
792 .stdout(Stdio::null())
793 .stderr(Stdio::null())
794 .spawn();
795 #[cfg(target_os = "macos")]
796 let _ = std::process::Command::new("open")
797 .arg(url)
798 .stdout(Stdio::null())
799 .stderr(Stdio::null())
800 .spawn();
801 #[cfg(target_os = "linux")]
802 let _ = std::process::Command::new("xdg-open")
803 .arg(url)
804 .stdout(Stdio::null())
805 .stderr(Stdio::null())
806 .spawn();
807}
808
809async fn shutdown_signal(server_mode: bool) {
811 if tokio::signal::ctrl_c().await.is_ok() {
812 println!();
813 if server_mode {
814 println!("Shutting down OxideSLOC server...");
815 } else {
816 println!("Shutting down OxideSLOC local web UI...");
817 }
818 println!("Server stopped cleanly.");
819 }
820}
821
822fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
824 use rustls_pemfile::{certs, private_key};
825 use std::io::BufReader;
826
827 let cert_bytes =
828 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
829 let key_bytes =
830 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
831
832 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
833 .collect::<std::result::Result<_, _>>()
834 .context("failed to parse TLS certificates")?;
835
836 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
837 .context("failed to parse TLS private key")?
838 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
839
840 rustls::ServerConfig::builder()
841 .with_no_client_auth()
842 .with_single_cert(cert_chain, key)
843 .context("failed to build TLS server config")
844}
845
846async fn serve_tls(
848 listener: tokio::net::TcpListener,
849 app: Router,
850 acceptor: tokio_rustls::TlsAcceptor,
851 server_mode: bool,
852) -> Result<()> {
853 use hyper_util::rt::{TokioExecutor, TokioIo};
854 use hyper_util::server::conn::auto::Builder as ConnBuilder;
855 use hyper_util::service::TowerToHyperService;
856 use tower::{Service, ServiceExt};
857
858 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
859
860 loop {
861 tokio::select! {
862 biased;
863 _ = tokio::signal::ctrl_c() => {
864 println!();
865 if server_mode {
866 println!("Shutting down OxideSLOC server...");
867 } else {
868 println!("Shutting down OxideSLOC local web UI...");
869 }
870 println!("Server stopped cleanly.");
871 return Ok(());
872 }
873 result = listener.accept() => {
874 let (tcp, peer_addr) = result.context("TLS accept failed")?;
875 let acceptor = acceptor.clone();
876 let mut factory = make_svc.clone();
877
878 tokio::spawn(async move {
879 let tls = match acceptor.accept(tcp).await {
880 Ok(s) => s,
881 Err(e) => {
882 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
883 return;
884 }
885 };
886 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
887 Ok(f) => match Service::call(f, peer_addr).await {
888 Ok(s) => s,
889 Err(_) => return,
890 },
891 Err(_) => return,
892 };
893 let io = TokioIo::new(tls);
894 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
895 .serve_connection(io, TowerToHyperService::new(svc))
896 .await
897 {
898 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
899 }
900 });
901 }
902 }
903 }
904}
905
906#[allow(clippy::too_many_lines)] async fn require_api_key(
908 State(state): State<AppState>,
910 req: Request<Body>,
911 next: Next,
912) -> Response {
913 if state.api_keys.is_empty() {
914 return next.run(req).await;
915 }
916
917 let keys = &state.api_keys;
918 let peer_ip = req
919 .extensions()
920 .get::<axum::extract::ConnectInfo<SocketAddr>>()
921 .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
922
923 let auth_header = req
925 .headers()
926 .get(header::AUTHORIZATION)
927 .and_then(|v| v.to_str().ok())
928 .and_then(|v| v.strip_prefix("Bearer "))
929 .map(str::to_owned);
930 let x_api_key = req
931 .headers()
932 .get("X-API-Key")
933 .and_then(|v| v.to_str().ok())
934 .map(str::to_owned);
935 let session_cookie = req
936 .headers()
937 .get(header::COOKIE)
938 .and_then(|v| v.to_str().ok())
939 .and_then(extract_session_cookie)
940 .map(str::to_owned);
941
942 let session_valid = session_cookie.as_deref().is_some_and(|tok| {
943 let now = Instant::now();
944 let mut sessions = state
945 .sessions
946 .lock()
947 .unwrap_or_else(std::sync::PoisonError::into_inner);
948 if let Some(&expiry) = sessions.get(tok) {
949 if now < expiry {
950 return true;
951 }
952 sessions.remove(tok);
953 }
954 false
955 });
956
957 let any_credential_provided =
958 auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
959
960 let valid = session_valid
961 || [&auth_header, &x_api_key]
962 .iter()
963 .filter_map(|o| o.as_deref())
964 .any(|k| {
965 keys.iter().any(|expected| {
966 use secrecy::ExposeSecret;
967 ct_eq(k, expected.expose_secret())
968 })
969 });
970
971 if valid {
972 return next.run(req).await;
973 }
974
975 if state.rate_limiter.is_auth_locked_out(peer_ip) {
976 tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
977 "Authentication locked out after repeated failures");
978 let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
979 let retry_after = HeaderValue::from_str(&remaining.to_string())
980 .unwrap_or(HeaderValue::from_static("3600"));
981 if is_browser_request(&req) {
982 let minutes = remaining.div_ceil(60).max(1);
983 let s = if minutes == 1 { "" } else { "s" };
984 let body = format!(
985 r#"<!doctype html><html><head><meta charset="utf-8">
986<title>Locked Out — OxideSLOC</title>
987<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
988h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
989</head><body>
990<h1>Too many failed sign-in attempts</h1>
991<p>Access from your IP is temporarily locked. Lockout expires in approximately
992<strong>{minutes} minute{s}</strong>.</p>
993<p>To clear immediately, restart the server.</p>
994<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
995threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
996</body></html>"#
997 );
998 let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
999 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1000 return resp;
1001 }
1002 let mut resp = (
1003 StatusCode::TOO_MANY_REQUESTS,
1004 format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
1005 )
1006 .into_response();
1007 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1008 return resp;
1009 }
1010
1011 if any_credential_provided {
1012 state.rate_limiter.record_auth_failure(peer_ip);
1014 let path = req.uri().path().to_owned();
1015 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
1016 "API key authentication failed");
1017 return (
1018 StatusCode::UNAUTHORIZED,
1019 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1020 "401 Unauthorized\n",
1021 )
1022 .into_response();
1023 }
1024
1025 if is_browser_request(&req) {
1029 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1030 let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
1031 let location = HeaderValue::from_str(&login_url)
1032 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
1033 let mut resp = StatusCode::FOUND.into_response();
1034 resp.headers_mut().insert(header::LOCATION, location);
1035 return resp;
1036 }
1037
1038 (
1039 StatusCode::UNAUTHORIZED,
1040 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1041 "401 Unauthorized\n",
1042 )
1043 .into_response()
1044}
1045
1046fn ct_eq(a: &str, b: &str) -> bool {
1047 use subtle::ConstantTimeEq;
1048 a.as_bytes().ct_eq(b.as_bytes()).into()
1049}
1050
1051fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
1052 cookie_header.split(';').find_map(|pair| {
1053 let pair = pair.trim();
1054 let (k, v) = pair.split_once('=')?;
1055 if k.trim() == "sloc_session" {
1056 Some(v.trim())
1057 } else {
1058 None
1059 }
1060 })
1061}
1062
1063fn is_browser_request(req: &Request<Body>) -> bool {
1064 req.headers()
1065 .get(header::ACCEPT)
1066 .and_then(|v| v.to_str().ok())
1067 .is_some_and(|a| a.contains("text/html"))
1068}
1069
1070fn urlencode_path(s: &str) -> String {
1071 let mut out = String::with_capacity(s.len());
1072 for b in s.bytes() {
1073 match b {
1074 b'A'..=b'Z'
1075 | b'a'..=b'z'
1076 | b'0'..=b'9'
1077 | b'-'
1078 | b'_'
1079 | b'.'
1080 | b'~'
1081 | b'/'
1082 | b'?'
1083 | b'='
1084 | b'&'
1085 | b'#' => {
1086 out.push(b as char);
1087 }
1088 _ => {
1089 use std::fmt::Write as _;
1090 write!(&mut out, "%{b:02X}").ok();
1091 }
1092 }
1093 }
1094 out
1095}
1096
1097#[derive(serde::Deserialize)]
1100struct LoginQuery {
1101 next: Option<String>,
1102 error: Option<String>,
1103}
1104
1105#[derive(serde::Deserialize)]
1106struct LoginFormData {
1107 key: String,
1108 next: Option<String>,
1109}
1110
1111async fn auth_login_get(
1112 State(state): State<AppState>,
1113 Query(query): Query<LoginQuery>,
1114 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1115) -> Response {
1116 if state.api_keys.is_empty() {
1117 let mut resp = StatusCode::FOUND.into_response();
1118 resp.headers_mut()
1119 .insert(header::LOCATION, HeaderValue::from_static("/"));
1120 return resp;
1121 }
1122 let has_error = query.error.as_deref() == Some("1");
1123 let next_url = query.next.unwrap_or_default();
1124 let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
1125 Html(
1126 LoginTemplate {
1127 csp_nonce,
1128 has_error,
1129 next_url,
1130 lockout_threshold,
1131 }
1132 .render()
1133 .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
1134 )
1135 .into_response()
1136}
1137
1138async fn auth_login_post(
1139 State(state): State<AppState>,
1140 axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
1141 Form(form): Form<LoginFormData>,
1142) -> Response {
1143 let peer_ip = peer_addr.ip();
1144 let next_url = form
1145 .next
1146 .as_deref()
1147 .filter(|s| !s.is_empty())
1148 .unwrap_or("/");
1149 let safe_next = if next_url.starts_with('/') && !next_url.starts_with("//") {
1150 next_url
1151 } else {
1152 "/"
1153 };
1154
1155 let valid = state.api_keys.iter().any(|expected| {
1156 use secrecy::ExposeSecret;
1157 ct_eq(&form.key, expected.expose_secret())
1158 });
1159
1160 if valid {
1161 const SESSION_SECS: u64 = 8 * 3600;
1162 let session_id = uuid::Uuid::new_v4().to_string();
1163 let expiry = Instant::now() + Duration::from_secs(SESSION_SECS);
1164 state
1165 .sessions
1166 .lock()
1167 .unwrap_or_else(std::sync::PoisonError::into_inner)
1168 .insert(session_id.clone(), expiry);
1169 let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
1170 let cookie_value = format!(
1171 "sloc_session={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_SECS}{secure_flag}",
1172 );
1173 let location =
1174 HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
1175 let cookie_hv = HeaderValue::from_str(&cookie_value)
1176 .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
1177 let mut resp = StatusCode::FOUND.into_response();
1178 resp.headers_mut().insert(header::LOCATION, location);
1179 resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
1180 resp
1181 } else {
1182 state.rate_limiter.record_auth_failure(peer_ip);
1183 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
1184 "Login form authentication failed");
1185 let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
1186 let location = HeaderValue::from_str(&error_url)
1187 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
1188 let mut resp = StatusCode::FOUND.into_response();
1189 resp.headers_mut().insert(header::LOCATION, location);
1190 resp
1191 }
1192}
1193
1194fn build_cors_layer(server_mode: bool) -> CorsLayer {
1195 if server_mode {
1196 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1197 .unwrap_or_default()
1198 .split(',')
1199 .filter(|s| !s.is_empty())
1200 .filter_map(|s| s.trim().parse().ok())
1201 .collect();
1202 if allowed.is_empty() {
1203 return CorsLayer::new();
1204 }
1205 CorsLayer::new()
1206 .allow_origin(AllowOrigin::list(allowed))
1207 .allow_methods(AllowMethods::list([
1208 axum::http::Method::GET,
1209 axum::http::Method::POST,
1210 ]))
1211 .allow_headers(AllowHeaders::list([
1212 axum::http::header::AUTHORIZATION,
1213 axum::http::header::CONTENT_TYPE,
1214 ]))
1215 } else {
1216 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1217 let s = origin.to_str().unwrap_or("");
1218 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1219 }))
1220 }
1221}
1222
1223async fn add_security_headers(
1224 State(state): State<AppState>,
1225 mut req: Request<Body>,
1226 next: Next,
1227) -> Response {
1228 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1229 req.extensions_mut().insert(CspNonce(nonce.clone()));
1230 let mut resp = next.run(req).await;
1231 let h = resp.headers_mut();
1232 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1233 h.insert(
1234 "X-Content-Type-Options",
1235 HeaderValue::from_static("nosniff"),
1236 );
1237 h.insert(
1238 "Referrer-Policy",
1239 HeaderValue::from_static("strict-origin-when-cross-origin"),
1240 );
1241 let csp = format!(
1242 "default-src 'self'; \
1243 style-src 'self' 'nonce-{nonce}'; \
1244 img-src 'self' data: blob:; \
1245 script-src 'self' 'nonce-{nonce}'; \
1246 font-src 'self' data:; \
1247 object-src 'none'; \
1248 frame-ancestors 'none'"
1249 );
1250 h.insert(
1251 "Content-Security-Policy",
1252 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1253 HeaderValue::from_static(
1254 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1255 )
1256 }),
1257 );
1258 h.insert(
1259 "X-Permitted-Cross-Domain-Policies",
1260 HeaderValue::from_static("none"),
1261 );
1262 h.insert(
1263 "Permissions-Policy",
1264 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1265 );
1266 h.insert(
1267 "Cross-Origin-Opener-Policy",
1268 HeaderValue::from_static("same-origin"),
1269 );
1270 h.insert(
1271 "Cross-Origin-Resource-Policy",
1272 HeaderValue::from_static("same-origin"),
1273 );
1274 if state.tls_enabled {
1275 h.insert(
1276 "Strict-Transport-Security",
1277 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1278 );
1279 }
1280 resp
1281}
1282
1283async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1284 let ip = req
1285 .extensions()
1286 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1287 .map(|c| c.0.ip())
1288 .or_else(|| {
1289 if state.trust_proxy {
1290 req.headers()
1291 .get("X-Forwarded-For")
1292 .and_then(|v| v.to_str().ok())
1293 .and_then(|s| s.split(',').next())
1294 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1295 } else {
1296 None
1297 }
1298 })
1299 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1300
1301 if !state.rate_limiter.is_allowed(ip) {
1302 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1303 path = %req.uri().path(), "Rate limit exceeded");
1304 return (
1305 StatusCode::TOO_MANY_REQUESTS,
1306 [(header::RETRY_AFTER, "60")],
1307 "429 Too Many Requests\n",
1308 )
1309 .into_response();
1310 }
1311 next.run(req).await
1312}
1313
1314async fn splash(
1315 State(state): State<AppState>,
1316 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1317) -> impl IntoResponse {
1318 let lan_ip = if state.server_mode {
1319 primary_lan_ip()
1320 } else {
1321 None
1322 };
1323 let port = state
1324 .base_config
1325 .web
1326 .bind_address
1327 .rsplit(':')
1328 .next()
1329 .and_then(|p| p.parse::<u16>().ok())
1330 .unwrap_or(4317);
1331 let template = SplashTemplate {
1332 csp_nonce,
1333 server_mode: state.server_mode,
1334 lan_ip,
1335 port,
1336 version: env!("CARGO_PKG_VERSION"),
1337 };
1338 Html(
1339 template
1340 .render()
1341 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1342 )
1343}
1344
1345async fn index(
1346 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1347 Query(query): Query<IndexQuery>,
1348) -> impl IntoResponse {
1349 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1350 let policy = query
1351 .mixed_line_policy
1352 .unwrap_or_else(|| "code_only".to_string());
1353 let behavior = query
1354 .binary_file_behavior
1355 .unwrap_or_else(|| "skip".to_string());
1356 let cfg = ScanConfig {
1357 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1358 path: query.path.unwrap_or_default(),
1359 include_globs: query.include_globs.unwrap_or_default(),
1360 exclude_globs: query.exclude_globs.unwrap_or_default(),
1361 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1362 mixed_line_policy: policy,
1363 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1364 != Some("off"),
1365 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1366 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1367 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1368 != Some("disabled"),
1369 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1370 binary_file_behavior: behavior,
1371 output_dir: query.output_dir.unwrap_or_default(),
1372 report_title: query.report_title.unwrap_or_default(),
1373 generate_html: query.generate_html.as_deref() != Some("off"),
1374 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1375 };
1376 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1377 } else {
1378 "{}".to_string()
1379 };
1380
1381 let git_repo = query.git_repo.unwrap_or_default();
1382 let git_ref = query.git_ref.unwrap_or_default();
1383
1384 let git_label = make_git_label(&git_repo, &git_ref);
1385 let git_output_dir = if git_label.is_empty() {
1386 String::new()
1387 } else {
1388 desktop_dir().join(&git_label).display().to_string()
1389 };
1390 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1391 let git_output_dir_json =
1392 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1393
1394 let template = IndexTemplate {
1395 version: env!("CARGO_PKG_VERSION"),
1396 prefill_json,
1397 csp_nonce,
1398 git_repo,
1399 git_ref,
1400 git_label_json,
1401 git_output_dir_json,
1402 };
1403
1404 Html(
1405 template
1406 .render()
1407 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1408 )
1409}
1410
1411async fn scan_setup_handler(
1412 State(state): State<AppState>,
1413 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1414) -> impl IntoResponse {
1415 let recent_scans_json = {
1416 let arr: Vec<serde_json::Value> = {
1417 let reg = state.registry.lock().await;
1418 reg.entries
1419 .iter()
1420 .rev()
1421 .take(6)
1422 .map(|e| {
1423 let run_dir = e
1424 .html_path
1425 .as_ref()
1426 .or(e.json_path.as_ref())
1427 .and_then(|p| p.parent().map(PathBuf::from));
1428 let config_val: Option<serde_json::Value> = run_dir
1429 .and_then(|d| find_scan_config_in_dir(&d))
1430 .and_then(|p| fs::read_to_string(&p).ok())
1431 .and_then(|s| serde_json::from_str(&s).ok());
1432 serde_json::json!({
1433 "project_label": e.project_label,
1434 "timestamp": fmt_la_time(e.timestamp_utc),
1435 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1436 "config": config_val,
1437 })
1438 })
1439 .collect()
1440 };
1441 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1442 };
1443
1444 let template = ScanSetupTemplate {
1445 version: env!("CARGO_PKG_VERSION"),
1446 recent_scans_json,
1447 csp_nonce,
1448 };
1449 Html(
1450 template
1451 .render()
1452 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1453 )
1454}
1455
1456async fn healthz() -> &'static str {
1457 "ok"
1458}
1459
1460async fn api_docs_handler(
1461 State(state): State<AppState>,
1462 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1463) -> impl IntoResponse {
1464 let has_api_key = !state.api_keys.is_empty();
1465 Html(
1466 ApiDocsTemplate {
1467 has_api_key,
1468 csp_nonce,
1469 version: env!("CARGO_PKG_VERSION"),
1470 }
1471 .render()
1472 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1473 )
1474}
1475
1476async fn chart_js_handler() -> impl IntoResponse {
1477 (
1478 [(
1479 header::CONTENT_TYPE,
1480 "application/javascript; charset=utf-8",
1481 )],
1482 CHART_JS,
1483 )
1484}
1485
1486#[derive(Debug, Deserialize)]
1487struct AnalyzeForm {
1488 path: String,
1489 git_repo: Option<String>,
1490 git_ref: Option<String>,
1491 mixed_line_policy: Option<MixedLinePolicy>,
1492 python_docstrings_as_comments: Option<String>,
1493 generated_file_detection: Option<String>,
1494 minified_file_detection: Option<String>,
1495 vendor_directory_detection: Option<String>,
1496 include_lockfiles: Option<String>,
1497 binary_file_behavior: Option<BinaryFileBehavior>,
1498 output_dir: Option<String>,
1499 report_title: Option<String>,
1500 report_header_footer: Option<String>,
1501 generate_html: Option<String>,
1502 generate_pdf: Option<String>,
1503 include_globs: Option<String>,
1504 exclude_globs: Option<String>,
1505 submodule_breakdown: Option<String>,
1506 coverage_file: Option<String>,
1507}
1508
1509#[allow(clippy::struct_excessive_bools)]
1510#[derive(Debug, Serialize, Deserialize, Clone)]
1511struct ScanConfig {
1512 oxide_sloc_version: String,
1513 path: String,
1514 include_globs: String,
1515 exclude_globs: String,
1516 submodule_breakdown: bool,
1517 mixed_line_policy: String,
1518 python_docstrings_as_comments: bool,
1519 generated_file_detection: bool,
1520 minified_file_detection: bool,
1521 vendor_directory_detection: bool,
1522 include_lockfiles: bool,
1523 binary_file_behavior: String,
1524 output_dir: String,
1525 report_title: String,
1526 generate_html: bool,
1527 generate_pdf: bool,
1528}
1529
1530#[derive(Debug, Deserialize, Default)]
1531struct IndexQuery {
1532 path: Option<String>,
1533 include_globs: Option<String>,
1534 exclude_globs: Option<String>,
1535 submodule_breakdown: Option<String>,
1536 mixed_line_policy: Option<String>,
1537 python_docstrings_as_comments: Option<String>,
1538 generated_file_detection: Option<String>,
1539 minified_file_detection: Option<String>,
1540 vendor_directory_detection: Option<String>,
1541 include_lockfiles: Option<String>,
1542 binary_file_behavior: Option<String>,
1543 output_dir: Option<String>,
1544 report_title: Option<String>,
1545 generate_html: Option<String>,
1546 generate_pdf: Option<String>,
1547 prefilled: Option<String>,
1548 git_repo: Option<String>,
1549 git_ref: Option<String>,
1550}
1551
1552#[derive(Debug, Deserialize)]
1553struct PreviewQuery {
1554 path: Option<String>,
1555 include_globs: Option<String>,
1556 exclude_globs: Option<String>,
1557}
1558
1559#[cfg(feature = "native-dialog")]
1560#[derive(Debug, Deserialize)]
1561struct PickDirectoryQuery {
1562 kind: Option<String>,
1563 current: Option<String>,
1564}
1565
1566#[cfg(not(feature = "native-dialog"))]
1567#[derive(Debug, Deserialize)]
1568struct PickDirectoryQuery {}
1569
1570#[derive(Debug, Deserialize, Default)]
1571struct ArtifactQuery {
1572 download: Option<String>,
1573}
1574
1575#[cfg(feature = "native-dialog")]
1576#[derive(Debug, Serialize)]
1577struct PickDirectoryResponse {
1578 selected_path: Option<String>,
1579 cancelled: bool,
1580}
1581
1582#[cfg(feature = "native-dialog")]
1583async fn pick_directory_handler(
1584 State(state): State<AppState>,
1585 Query(query): Query<PickDirectoryQuery>,
1586) -> Response {
1587 if state.server_mode {
1588 return StatusCode::NOT_FOUND.into_response();
1589 }
1590
1591 let is_coverage = query.kind.as_deref() == Some("coverage");
1592 let title = match query.kind.as_deref() {
1593 Some("output") => "Select output directory",
1594 Some("reports") => "Select folder containing saved reports",
1595 Some("coverage") => "Select LCOV coverage file",
1596 _ => "Select project directory",
1597 }
1598 .to_owned();
1599 let current = query.current.clone();
1600
1601 let picked = tokio::task::spawn_blocking(move || {
1602 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1605 let fg_tid = win_dialog_focus::attach_to_foreground();
1606 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1607 win_dialog_focus::flash_dialog_when_ready(title.clone());
1608
1609 let mut dialog = rfd::FileDialog::new().set_title(&title);
1610 if let Some(current) = current.as_deref() {
1611 let resolved = resolve_input_path(current);
1612 let seed = if resolved.is_dir() {
1613 Some(resolved)
1614 } else {
1615 resolved.parent().map(Path::to_path_buf)
1616 };
1617 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1618 dialog = dialog.set_directory(seed_dir);
1619 }
1620 }
1621 let result = if is_coverage {
1622 dialog
1623 .add_filter(
1624 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1625 &["info", "lcov", "xml"],
1626 )
1627 .pick_file()
1628 } else {
1629 dialog.pick_folder()
1630 };
1631
1632 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1633 win_dialog_focus::detach_from_foreground(fg_tid);
1634
1635 result
1636 })
1637 .await
1638 .unwrap_or(None);
1639
1640 Json(PickDirectoryResponse {
1641 selected_path: picked.as_ref().map(|p| display_path(p)),
1642 cancelled: picked.is_none(),
1643 })
1644 .into_response()
1645}
1646
1647#[cfg(not(feature = "native-dialog"))]
1648async fn pick_directory_handler(
1649 State(_state): State<AppState>,
1650 Query(_query): Query<PickDirectoryQuery>,
1651) -> Response {
1652 StatusCode::NOT_FOUND.into_response()
1653}
1654
1655#[cfg(feature = "native-dialog")]
1656async fn pick_file_handler(State(state): State<AppState>) -> Response {
1657 if state.server_mode {
1658 return StatusCode::NOT_FOUND.into_response();
1659 }
1660 let picked = tokio::task::spawn_blocking(|| {
1661 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1662 let fg_tid = win_dialog_focus::attach_to_foreground();
1663 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1664 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1665
1666 let result = rfd::FileDialog::new()
1667 .set_title("Select HTML report")
1668 .add_filter("HTML report", &["html"])
1669 .pick_file();
1670
1671 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1672 win_dialog_focus::detach_from_foreground(fg_tid);
1673
1674 result
1675 })
1676 .await
1677 .unwrap_or(None);
1678 Json(PickDirectoryResponse {
1679 selected_path: picked.as_ref().map(|p| display_path(p)),
1680 cancelled: picked.is_none(),
1681 })
1682 .into_response()
1683}
1684
1685#[cfg(not(feature = "native-dialog"))]
1686async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1687 StatusCode::NOT_FOUND.into_response()
1688}
1689
1690#[derive(Deserialize)]
1691struct LocateReportForm {
1692 file_path: String,
1693}
1694
1695fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1697 let html = ErrorTemplate {
1698 message: message.into(),
1699 last_report_url: Some("/view-reports".to_string()),
1700 last_report_label: Some("View Reports".to_string()),
1701 csp_nonce: csp_nonce.to_owned(),
1702 }
1703 .render()
1704 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1705 Html(html).into_response()
1706}
1707
1708fn registry_entry_from_run(
1710 run: &AnalysisRun,
1711 json_path: PathBuf,
1712 html_path: PathBuf,
1713) -> RegistryEntry {
1714 let project_label = run.input_roots.first().map_or_else(
1715 || "Unknown Project".to_string(),
1716 |r| sanitize_project_label(r),
1717 );
1718 RegistryEntry {
1719 run_id: run.tool.run_id.clone(),
1720 timestamp_utc: run.tool.timestamp_utc,
1721 project_label,
1722 input_roots: run.input_roots.clone(),
1723 json_path: Some(json_path),
1724 html_path: Some(html_path),
1725 pdf_path: None,
1726 summary: ScanSummarySnapshot {
1727 files_analyzed: run.summary_totals.files_analyzed,
1728 files_skipped: run.summary_totals.files_skipped,
1729 total_physical_lines: run.summary_totals.total_physical_lines,
1730 code_lines: run.summary_totals.code_lines,
1731 comment_lines: run.summary_totals.comment_lines,
1732 blank_lines: run.summary_totals.blank_lines,
1733 functions: run.summary_totals.functions,
1734 classes: run.summary_totals.classes,
1735 variables: run.summary_totals.variables,
1736 imports: run.summary_totals.imports,
1737 test_count: run.summary_totals.test_count,
1738 },
1739 git_branch: None,
1740 git_commit: None,
1741 git_author: None,
1742 git_tags: None,
1743 git_nearest_tag: None,
1744 git_commit_date: None,
1745 }
1746}
1747
1748pub(crate) async fn register_artifacts_in_registry(
1751 state: &AppState,
1752 label: &str,
1753 run: &AnalysisRun,
1754 artifacts: &RunArtifacts,
1755) {
1756 let Some(json_path) = artifacts.json_path.clone() else {
1757 return;
1758 };
1759 let Some(html_path) = artifacts.html_path.clone() else {
1760 return;
1761 };
1762 let mut entry = registry_entry_from_run(run, json_path, html_path);
1763 entry.project_label = label.to_owned();
1764 let mut reg = state.registry.lock().await;
1765 reg.add_entry(entry);
1766 let _ = reg.save(&state.registry_path);
1767}
1768
1769#[allow(clippy::result_large_err)]
1774fn validate_locate_request(
1775 state: &AppState,
1776 file_path: &str,
1777 csp_nonce: &str,
1778) -> Result<(PathBuf, PathBuf), Response> {
1779 let file_ext = Path::new(file_path)
1780 .extension()
1781 .and_then(|e| e.to_str())
1782 .unwrap_or("")
1783 .to_ascii_lowercase();
1784 if file_ext != "html" {
1785 return Err(locate_report_error(
1786 "Only .html report files can be located via this form.",
1787 csp_nonce,
1788 ));
1789 }
1790 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1791 Ok(p) => strip_unc_prefix(p),
1792 Err(_) => {
1793 return Err(locate_report_error(
1794 "Report file not found or path is invalid.",
1795 csp_nonce,
1796 ));
1797 }
1798 };
1799 if state.server_mode {
1800 let output_root = resolve_output_root(None);
1801 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1802 if !html_path.starts_with(&canonical_root) {
1803 return Err(locate_report_error(
1804 "Report file must be within the configured output directory.",
1805 csp_nonce,
1806 ));
1807 }
1808 }
1809 let parent = match html_path.parent() {
1810 Some(p) => p.to_path_buf(),
1811 None => {
1812 return Err(locate_report_error(
1813 "Report file has no parent directory.",
1814 csp_nonce,
1815 ));
1816 }
1817 };
1818 Ok((html_path, parent))
1819}
1820
1821fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1823 if server_mode {
1824 String::new()
1825 } else {
1826 format!("\n\nFile: {}", path.display())
1827 }
1828}
1829
1830async fn locate_report_handler(
1831 State(state): State<AppState>,
1832 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1833 Form(form): Form<LocateReportForm>,
1834) -> impl IntoResponse {
1835 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1836 Ok(v) => v,
1837 Err(resp) => return resp,
1838 };
1839
1840 let json_candidate = parent.join("result.json");
1841 let mut reg = state.registry.lock().await;
1842 let entry_idx = reg.entries.iter().position(|e| {
1844 let json_match = e
1845 .json_path
1846 .as_ref()
1847 .and_then(|p| p.parent())
1848 .is_some_and(|p| p == parent);
1849 let html_match = e
1850 .html_path
1851 .as_ref()
1852 .and_then(|p| p.parent())
1853 .is_some_and(|p| p == parent);
1854 json_match || html_match
1855 });
1856 if let Some(idx) = entry_idx {
1857 reg.entries[idx].html_path = Some(html_path);
1858 let _ = reg.save(&state.registry_path);
1859 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1860 }
1861 if json_candidate.exists() {
1863 match read_json(&json_candidate) {
1864 Ok(run) => {
1865 let entry = registry_entry_from_run(&run, json_candidate, html_path);
1866 reg.add_entry(entry);
1867 let _ = reg.save(&state.registry_path);
1868 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1869 }
1870 Err(e) => {
1871 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1872 let err_detail = if state.server_mode {
1873 String::new()
1874 } else {
1875 format!("\n\nError: {e}")
1876 };
1877 return locate_report_error(
1878 format!(
1879 "Could not link this report.\n\nA 'result.json' was found but could not \
1880 be parsed — it may have been saved by an older version of OxideSLOC. \
1881 Re-running the analysis will create a fresh, compatible \
1882 record.{file_hint}{err_detail}"
1883 ),
1884 &csp_nonce,
1885 );
1886 }
1887 }
1888 }
1889 drop(reg);
1890 let file_hint = locate_path_hint(state.server_mode, &html_path);
1891 locate_report_error(
1892 format!(
1893 "Could not link this report.\n\nNo matching scan record was found, and no \
1894 'result.json' was found in the same folder.{file_hint}"
1895 ),
1896 &csp_nonce,
1897 )
1898}
1899
1900fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1902 fs::read_dir(dir)
1903 .ok()?
1904 .flatten()
1905 .map(|e| e.path())
1906 .find(|p| {
1907 p.is_file()
1908 && p.file_stem()
1909 .and_then(|n| n.to_str())
1910 .is_some_and(|n| n.starts_with("result"))
1911 && p.extension()
1912 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1913 })
1914}
1915
1916#[derive(Deserialize)]
1917struct LocateReportsDirForm {
1918 folder_path: String,
1919}
1920
1921#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
1923 State(state): State<AppState>,
1925 Form(form): Form<LocateReportsDirForm>,
1926) -> impl IntoResponse {
1927 if state.server_mode {
1928 return StatusCode::NOT_FOUND.into_response();
1929 }
1930 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1931 Ok(p) => strip_unc_prefix(p),
1932 Err(_) => {
1933 return axum::response::Redirect::to(
1934 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1935 )
1936 .into_response();
1937 }
1938 };
1939 if !folder.is_dir() {
1940 return axum::response::Redirect::to(
1941 "/view-reports?error=Selected+path+is+not+a+directory.",
1942 )
1943 .into_response();
1944 }
1945
1946 let mut candidates: Vec<PathBuf> = Vec::new();
1949 if let Some(j) = find_result_json_in_dir(&folder) {
1950 candidates.push(j);
1951 }
1952 if let Ok(dir_entries) = fs::read_dir(&folder) {
1953 for entry in dir_entries.flatten() {
1954 let sub = entry.path();
1955 if sub.is_dir() {
1956 if let Some(j) = find_result_json_in_dir(&sub) {
1957 candidates.push(j);
1958 }
1959 }
1960 }
1961 }
1962
1963 if candidates.is_empty() {
1964 return axum::response::Redirect::to(
1965 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1966 )
1967 .into_response();
1968 }
1969
1970 let mut linked_count: usize = 0;
1971 let mut reg = state.registry.lock().await;
1972 for json_path in candidates {
1973 let parent = match json_path.parent() {
1974 Some(p) => p.to_path_buf(),
1975 None => continue,
1976 };
1977 let already = reg.entries.iter().any(|e| {
1980 let dir_match = e
1981 .json_path
1982 .as_ref()
1983 .and_then(|p| p.parent())
1984 .is_some_and(|p| p == parent)
1985 || e.html_path
1986 .as_ref()
1987 .and_then(|p| p.parent())
1988 .is_some_and(|p| p == parent);
1989 dir_match
1990 && (e.json_path.as_ref().is_some_and(|p| p.exists())
1991 || e.html_path.as_ref().is_some_and(|p| p.exists()))
1992 });
1993 if already {
1994 continue;
1995 }
1996 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1998 rd.flatten()
1999 .map(|e| e.path())
2000 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2001 });
2002 let Ok(run) = read_json(&json_path) else {
2003 continue;
2004 };
2005 let project_label = run.input_roots.first().map_or_else(
2006 || "Unknown Project".to_string(),
2007 |r| sanitize_project_label(r),
2008 );
2009 let entry = RegistryEntry {
2010 run_id: run.tool.run_id.clone(),
2011 timestamp_utc: run.tool.timestamp_utc,
2012 project_label,
2013 input_roots: run.input_roots.clone(),
2014 json_path: Some(json_path),
2015 html_path,
2016 pdf_path: None,
2017 summary: ScanSummarySnapshot {
2018 files_analyzed: run.summary_totals.files_analyzed,
2019 files_skipped: run.summary_totals.files_skipped,
2020 total_physical_lines: run.summary_totals.total_physical_lines,
2021 code_lines: run.summary_totals.code_lines,
2022 comment_lines: run.summary_totals.comment_lines,
2023 blank_lines: run.summary_totals.blank_lines,
2024 functions: run.summary_totals.functions,
2025 classes: run.summary_totals.classes,
2026 variables: run.summary_totals.variables,
2027 imports: run.summary_totals.imports,
2028 test_count: run.summary_totals.test_count,
2029 },
2030 git_branch: run.git_branch.clone(),
2031 git_commit: run.git_commit_short.clone(),
2032 git_author: run.git_commit_author.clone(),
2033 git_tags: run.git_tags.clone(),
2034 git_nearest_tag: run.git_nearest_tag.clone(),
2035 git_commit_date: run.git_commit_date.clone(),
2036 };
2037 reg.add_entry(entry);
2038 linked_count += 1;
2039 }
2040 let _ = reg.save(&state.registry_path);
2041 drop(reg);
2042
2043 if linked_count == 0 {
2044 return axum::response::Redirect::to(
2045 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2046 )
2047 .into_response();
2048 }
2049 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2050}
2051
2052#[derive(Deserialize)]
2053struct RelocateScanForm {
2054 run_id: String,
2055 folder_path: String,
2056 redirect_url: String,
2057}
2058
2059#[allow(clippy::too_many_lines)] async fn relocate_scan_handler(
2061 State(state): State<AppState>,
2063 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2064 Form(form): Form<RelocateScanForm>,
2065) -> impl IntoResponse {
2066 if state.server_mode {
2067 return StatusCode::NOT_FOUND.into_response();
2068 }
2069
2070 let run_id = form.run_id.trim().to_string();
2071 let redirect_url = form.redirect_url.trim().to_string();
2072
2073 let run_exists = {
2074 let reg = state.registry.lock().await;
2075 reg.find_by_run_id(&run_id).is_some()
2076 };
2077 if !run_exists {
2078 let html = ErrorTemplate {
2079 message: format!("Run ID '{run_id}' not found in registry."),
2080 last_report_url: Some("/compare-scans".to_string()),
2081 last_report_label: Some("Compare Scans".to_string()),
2082 csp_nonce: csp_nonce.clone(),
2083 }
2084 .render()
2085 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2086 return Html(html).into_response();
2087 }
2088
2089 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2090 Ok(p) => strip_unc_prefix(p),
2091 Err(_) => {
2092 return missing_scan_relocate_response(
2093 "Folder not found or path is invalid.",
2094 &run_id,
2095 form.folder_path.trim(),
2096 &redirect_url,
2097 false,
2098 &csp_nonce,
2099 );
2100 }
2101 };
2102
2103 if !folder.is_dir() {
2104 return missing_scan_relocate_response(
2105 "Selected path is not a directory.",
2106 &run_id,
2107 &folder.display().to_string(),
2108 &redirect_url,
2109 false,
2110 &csp_nonce,
2111 );
2112 }
2113
2114 let json_candidates: Vec<PathBuf> = fs::read_dir(&folder)
2115 .ok()
2116 .into_iter()
2117 .flatten()
2118 .flatten()
2119 .map(|e| e.path())
2120 .filter(|p| {
2121 p.is_file()
2122 && p.file_stem()
2123 .and_then(|n| n.to_str())
2124 .is_some_and(|n| n.starts_with("result"))
2125 && p.extension()
2126 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2127 })
2128 .collect();
2129
2130 if json_candidates.is_empty() {
2131 return missing_scan_relocate_response(
2132 &format!(
2133 "No result JSON files found in the selected folder.\nSearched: {}",
2134 folder.display()
2135 ),
2136 &run_id,
2137 &folder.display().to_string(),
2138 &redirect_url,
2139 false,
2140 &csp_nonce,
2141 );
2142 }
2143
2144 let mut matched_json: Option<PathBuf> = None;
2145 for candidate in &json_candidates {
2146 if let Ok(run) = read_json(candidate) {
2147 if run.tool.run_id == run_id {
2148 matched_json = Some(candidate.clone());
2149 break;
2150 }
2151 }
2152 }
2153
2154 let Some(json_path) = matched_json else {
2155 return missing_scan_relocate_response(
2156 &format!(
2157 "No matching scan found in the selected folder.\n\
2158 The JSON files present do not contain run ID: {run_id}\n\
2159 Searched: {}",
2160 folder.display()
2161 ),
2162 &run_id,
2163 &folder.display().to_string(),
2164 &redirect_url,
2165 false,
2166 &csp_nonce,
2167 );
2168 };
2169
2170 let html_path = fs::read_dir(&folder)
2171 .ok()
2172 .into_iter()
2173 .flatten()
2174 .flatten()
2175 .map(|e| e.path())
2176 .find(|p| {
2177 p.is_file()
2178 && p.file_stem()
2179 .and_then(|n| n.to_str())
2180 .is_some_and(|n| n.starts_with("result"))
2181 && p.extension()
2182 .is_some_and(|e| e.eq_ignore_ascii_case("html"))
2183 });
2184 let pdf_path = fs::read_dir(&folder)
2185 .ok()
2186 .into_iter()
2187 .flatten()
2188 .flatten()
2189 .map(|e| e.path())
2190 .find(|p| {
2191 p.is_file()
2192 && p.file_stem()
2193 .and_then(|n| n.to_str())
2194 .is_some_and(|n| n.starts_with("result"))
2195 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
2196 });
2197
2198 {
2199 let mut reg = state.registry.lock().await;
2200 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2201 entry.json_path = Some(json_path);
2202 if let Some(hp) = html_path {
2203 entry.html_path = Some(hp);
2204 }
2205 if let Some(pp) = pdf_path {
2206 entry.pdf_path = Some(pp);
2207 }
2208 }
2209 let _ = reg.save(&state.registry_path);
2210 }
2211
2212 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2213 redirect_url
2214 } else {
2215 "/compare-scans".to_string()
2216 };
2217 axum::response::Redirect::to(&safe_redirect).into_response()
2218}
2219
2220fn missing_scan_relocate_response(
2221 message: &str,
2222 run_id: &str,
2223 folder_hint: &str,
2224 redirect_url: &str,
2225 server_mode: bool,
2226 csp_nonce: &str,
2227) -> axum::response::Response {
2228 let html = RelocateScanTemplate {
2229 message: message.to_string(),
2230 run_id: run_id.to_string(),
2231 folder_hint: folder_hint.to_string(),
2232 redirect_url: redirect_url.to_string(),
2233 server_mode,
2234 csp_nonce: csp_nonce.to_owned(),
2235 }
2236 .render()
2237 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2238 (StatusCode::NOT_FOUND, Html(html)).into_response()
2239}
2240
2241fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2246 let mut candidates: Vec<PathBuf> = Vec::new();
2247 if let Some(j) = find_result_json_in_dir(folder) {
2248 candidates.push(j);
2249 }
2250 if let Ok(dir_entries) = fs::read_dir(folder) {
2251 for entry in dir_entries.flatten() {
2252 let sub = entry.path();
2253 if sub.is_dir() {
2254 if let Some(j) = find_result_json_in_dir(&sub) {
2255 candidates.push(j);
2256 }
2257 }
2258 }
2259 }
2260
2261 let mut linked = 0usize;
2262 for json_path in candidates {
2263 let parent = match json_path.parent() {
2264 Some(p) => p.to_path_buf(),
2265 None => continue,
2266 };
2267 let already = reg.entries.iter().any(|e| {
2268 let dir_match = e
2269 .json_path
2270 .as_ref()
2271 .and_then(|p| p.parent())
2272 .is_some_and(|p| p == parent)
2273 || e.html_path
2274 .as_ref()
2275 .and_then(|p| p.parent())
2276 .is_some_and(|p| p == parent);
2277 dir_match
2278 && (e.json_path.as_ref().is_some_and(|p| p.exists())
2279 || e.html_path.as_ref().is_some_and(|p| p.exists()))
2280 });
2281 if already {
2282 continue;
2283 }
2284 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2285 rd.flatten()
2286 .map(|e| e.path())
2287 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2288 });
2289 let Ok(run) = read_json(&json_path) else {
2290 continue;
2291 };
2292 let project_label = run.input_roots.first().map_or_else(
2293 || "Unknown Project".to_string(),
2294 |r| sanitize_project_label(r),
2295 );
2296 let entry = RegistryEntry {
2297 run_id: run.tool.run_id.clone(),
2298 timestamp_utc: run.tool.timestamp_utc,
2299 project_label,
2300 input_roots: run.input_roots.clone(),
2301 json_path: Some(json_path),
2302 html_path,
2303 pdf_path: None,
2304 summary: ScanSummarySnapshot {
2305 files_analyzed: run.summary_totals.files_analyzed,
2306 files_skipped: run.summary_totals.files_skipped,
2307 total_physical_lines: run.summary_totals.total_physical_lines,
2308 code_lines: run.summary_totals.code_lines,
2309 comment_lines: run.summary_totals.comment_lines,
2310 blank_lines: run.summary_totals.blank_lines,
2311 functions: run.summary_totals.functions,
2312 classes: run.summary_totals.classes,
2313 variables: run.summary_totals.variables,
2314 imports: run.summary_totals.imports,
2315 test_count: run.summary_totals.test_count,
2316 },
2317 git_branch: run.git_branch.clone(),
2318 git_commit: run.git_commit_short.clone(),
2319 git_author: run.git_commit_author.clone(),
2320 git_tags: run.git_tags.clone(),
2321 git_nearest_tag: run.git_nearest_tag.clone(),
2322 git_commit_date: run.git_commit_date.clone(),
2323 };
2324 reg.add_entry(entry);
2325 linked += 1;
2326 }
2327 linked
2328}
2329
2330async fn auto_scan_watched_dirs(state: &AppState) {
2332 let dirs: Vec<PathBuf> = {
2333 let wd = state.watched_dirs.lock().await;
2334 wd.dirs.clone()
2335 };
2336 if dirs.is_empty() {
2337 return;
2338 }
2339 let mut reg = state.registry.lock().await;
2340 let mut total = 0usize;
2341 for dir in &dirs {
2342 if dir.is_dir() {
2343 total += scan_folder_into_registry(dir, &mut reg);
2344 }
2345 }
2346 if total > 0 {
2347 let _ = reg.save(&state.registry_path);
2348 }
2349}
2350
2351#[derive(Deserialize)]
2354struct WatchedDirForm {
2355 folder_path: String,
2356 #[serde(default = "default_redirect")]
2357 redirect_to: String,
2358}
2359
2360fn default_redirect() -> String {
2361 "/view-reports".to_string()
2362}
2363
2364#[derive(Deserialize)]
2365struct WatchedDirRefreshForm {
2366 #[serde(default = "default_redirect")]
2367 redirect_to: String,
2368}
2369
2370fn safe_redirect(dest: &str) -> &str {
2374 if dest.starts_with('/') {
2375 dest
2376 } else {
2377 "/"
2378 }
2379}
2380
2381async fn add_watched_dir_handler(
2384 State(state): State<AppState>,
2385 Form(form): Form<WatchedDirForm>,
2386) -> impl IntoResponse {
2387 if state.server_mode {
2388 return StatusCode::NOT_FOUND.into_response();
2389 }
2390 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2391 strip_unc_prefix(p)
2392 } else {
2393 let dest = format!(
2394 "{}?error=Folder+not+found+or+path+is+invalid.",
2395 safe_redirect(&form.redirect_to)
2396 );
2397 return axum::response::Redirect::to(&dest).into_response();
2398 };
2399 if !folder.is_dir() {
2400 let dest = format!(
2401 "{}?error=Selected+path+is+not+a+directory.",
2402 safe_redirect(&form.redirect_to)
2403 );
2404 return axum::response::Redirect::to(&dest).into_response();
2405 }
2406
2407 {
2409 let mut wd = state.watched_dirs.lock().await;
2410 wd.add(folder.clone());
2411 let _ = wd.save(&state.watched_dirs_path);
2412 }
2413
2414 let linked = {
2416 let mut reg = state.registry.lock().await;
2417 let n = scan_folder_into_registry(&folder, &mut reg);
2418 if n > 0 {
2419 let _ = reg.save(&state.registry_path);
2420 }
2421 n
2422 };
2423
2424 let dest = if linked > 0 {
2425 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2426 } else {
2427 format!(
2428 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2429 safe_redirect(&form.redirect_to)
2430 )
2431 };
2432 axum::response::Redirect::to(&dest).into_response()
2433}
2434
2435async fn remove_watched_dir_handler(
2436 State(state): State<AppState>,
2437 Form(form): Form<WatchedDirForm>,
2438) -> impl IntoResponse {
2439 if state.server_mode {
2440 return StatusCode::NOT_FOUND.into_response();
2441 }
2442 let folder = PathBuf::from(&form.folder_path);
2443 {
2444 let mut wd = state.watched_dirs.lock().await;
2445 wd.remove(&folder);
2446 let _ = wd.save(&state.watched_dirs_path);
2447 }
2448 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2449}
2450
2451async fn refresh_watched_dirs_handler(
2452 State(state): State<AppState>,
2453 Form(form): Form<WatchedDirRefreshForm>,
2454) -> impl IntoResponse {
2455 if state.server_mode {
2456 return StatusCode::NOT_FOUND.into_response();
2457 }
2458 let dirs: Vec<PathBuf> = {
2459 let wd = state.watched_dirs.lock().await;
2460 wd.dirs.clone()
2461 };
2462 let mut total = 0usize;
2463 {
2464 let mut reg = state.registry.lock().await;
2465 for dir in &dirs {
2466 if dir.is_dir() {
2467 total += scan_folder_into_registry(dir, &mut reg);
2468 }
2469 }
2470 if total > 0 {
2471 let _ = reg.save(&state.registry_path);
2472 }
2473 }
2474 let dest = if total > 0 {
2475 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2476 } else {
2477 safe_redirect(&form.redirect_to).to_owned()
2478 };
2479 axum::response::Redirect::to(&dest).into_response()
2480}
2481
2482#[derive(Debug, Deserialize)]
2483struct OpenPathQuery {
2484 path: Option<String>,
2485}
2486
2487async fn open_path_handler(
2488 State(state): State<AppState>,
2489 Query(query): Query<OpenPathQuery>,
2490) -> impl IntoResponse {
2491 if state.server_mode {
2492 return StatusCode::NOT_FOUND.into_response();
2493 }
2494 let raw = match query.path.as_deref() {
2495 Some(p) if !p.is_empty() => p,
2496 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2497 };
2498
2499 let target = match fs::canonicalize(raw) {
2503 Ok(canonical) if canonical.is_file() => match canonical.parent() {
2504 Some(p) => p.to_path_buf(),
2505 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2506 },
2507 Ok(canonical) if canonical.is_dir() => canonical,
2508 Ok(_) => {
2509 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2510 }
2511 Err(_) => {
2512 let mut ancestor = std::path::Path::new(raw);
2514 loop {
2515 match ancestor.parent() {
2516 Some(p) => {
2517 ancestor = p;
2518 if ancestor.is_dir() {
2519 break;
2520 }
2521 }
2522 None => {
2523 return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2524 .into_response();
2525 }
2526 }
2527 }
2528 ancestor.to_path_buf()
2529 }
2530 };
2531
2532 #[cfg(target_os = "windows")]
2533 {
2534 let ps_cmd = "Add-Type -TypeDefinition \
2538 'using System;using System.Runtime.InteropServices;\
2539 public class WF{\
2540 [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2541 [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2542 }'; \
2543 $p=$env:SLOC_OPEN_PATH; \
2544 $sh=New-Object -ComObject Shell.Application; \
2545 $sh.Open($p); \
2546 Start-Sleep -Milliseconds 600; \
2547 foreach($w in $sh.Windows()){ \
2548 try{ \
2549 if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2550 [System.IO.Path]::GetFullPath($p)){ \
2551 [WF]::ShowWindow($w.HWND,3); \
2552 [WF]::SetForegroundWindow($w.HWND); \
2553 break \
2554 } \
2555 }catch{} \
2556 }";
2557 let _ = std::process::Command::new("powershell")
2558 .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2559 .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2560 .stdout(Stdio::null())
2561 .stderr(Stdio::null())
2562 .spawn();
2563 }
2564 #[cfg(target_os = "macos")]
2565 let _ = std::process::Command::new("open")
2566 .arg(&target)
2567 .stdout(Stdio::null())
2568 .stderr(Stdio::null())
2569 .spawn();
2570 #[cfg(target_os = "linux")]
2571 let _ = std::process::Command::new("xdg-open")
2572 .arg(&target)
2573 .stdout(Stdio::null())
2574 .stderr(Stdio::null())
2575 .spawn();
2576
2577 (StatusCode::OK, "ok").into_response()
2578}
2579
2580async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2581 let (content_type, bytes): (&'static str, &'static [u8]) =
2582 match (folder.as_str(), file.as_str()) {
2583 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2584 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2585 ("icons", "c.png") => ("image/png", IMG_ICON_C),
2586 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2587 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2588 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2589 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2590 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2591 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2592 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2593 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2594 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2595 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2596 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2597 ("icons", "r.png") => ("image/png", IMG_ICON_R),
2598 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2599 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2600 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2601 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2602 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2603 _ => return StatusCode::NOT_FOUND.into_response(),
2604 };
2605 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2606}
2607
2608async fn preview_handler(
2609 State(state): State<AppState>,
2610 Query(query): Query<PreviewQuery>,
2611) -> impl IntoResponse {
2612 let raw_path = query
2613 .path
2614 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2615 let resolved = resolve_input_path(&raw_path);
2616
2617 if state.server_mode {
2618 let config = &state.base_config;
2619 if config.discovery.allowed_scan_roots.is_empty() {
2620 return Html(
2621 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2622 );
2623 }
2624 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2625 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2626 fs::canonicalize(root)
2627 .ok()
2628 .is_some_and(|r| canonical.starts_with(&r))
2629 });
2630 if !allowed {
2631 return Html(
2632 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2633 );
2634 }
2635 }
2636
2637 let include_patterns = split_patterns(query.include_globs.as_deref());
2638 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2639
2640 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2641 Ok(html) => Html(html),
2642 Err(err) => Html(format!(
2643 r#"<div class="preview-error">Preview failed: {}</div>"#,
2644 escape_html(&err.to_string())
2645 )),
2646 }
2647}
2648
2649#[derive(Debug, Deserialize, Default)]
2650struct SuggestCoverageQuery {
2651 path: Option<String>,
2652}
2653
2654async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2655 const CANDIDATES: &[&str] = &[
2656 "coverage/lcov.info",
2658 "lcov.info",
2659 "target/llvm-cov/lcov.info",
2660 "target/coverage/lcov.info",
2661 "target/debug/coverage/lcov.info",
2662 "coverage/coverage.lcov",
2663 "build/coverage/lcov.info",
2664 "reports/lcov.info",
2665 "coverage.xml",
2667 "coverage/coverage.xml",
2668 "target/site/cobertura/coverage.xml",
2669 "build/reports/coverage/coverage.xml",
2670 "target/site/jacoco/jacoco.xml",
2672 "build/reports/jacoco/test/jacocoTestReport.xml",
2673 "build/reports/jacoco/jacocoTestReport.xml",
2674 "build/jacoco/jacoco.xml",
2675 ];
2676 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2677 let found = CANDIDATES
2678 .iter()
2679 .map(|rel| root.join(rel))
2680 .find(|p| p.is_file())
2681 .map(|p| display_path(&p));
2682
2683 let (tool, hint) = detect_coverage_tool(&root);
2684 Json(serde_json::json!({ "found": found, "tool": tool, "hint": hint }))
2685}
2686
2687fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2690 if root.join("Cargo.toml").is_file() {
2691 return (
2692 Some("cargo-llvm-cov"),
2693 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2694 );
2695 }
2696 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2697 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2698 }
2699 if root.join("pom.xml").is_file() {
2700 return (Some("jacoco"), Some("mvn test jacoco:report"));
2701 }
2702 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2703 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2704 }
2705 (None, None)
2706}
2707
2708#[allow(clippy::result_large_err)]
2710fn validate_server_scan_path(
2711 config: &sloc_config::AppConfig,
2712 resolved_path: &Path,
2713 csp_nonce: &str,
2714) -> Result<(), Response> {
2715 if config.discovery.allowed_scan_roots.is_empty() {
2716 let template = ErrorTemplate {
2717 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2718 Set allowed_scan_roots in the server config to permit scanning."
2719 .to_string(),
2720 last_report_url: None,
2721 last_report_label: None,
2722 csp_nonce: csp_nonce.to_owned(),
2723 };
2724 return Err((
2725 StatusCode::FORBIDDEN,
2726 Html(
2727 template
2728 .render()
2729 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2730 ),
2731 )
2732 .into_response());
2733 }
2734 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2735 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2736 fs::canonicalize(root)
2737 .ok()
2738 .is_some_and(|r| canonical.starts_with(&r))
2739 });
2740 if !allowed {
2741 tracing::warn!(event = "path_rejected", path = %canonical.display(),
2742 "Scan path not in allowed_scan_roots");
2743 let template = ErrorTemplate {
2744 message: "The requested path is not within an allowed scan directory.".to_string(),
2745 last_report_url: None,
2746 last_report_label: None,
2747 csp_nonce: csp_nonce.to_owned(),
2748 };
2749 return Err((
2750 StatusCode::FORBIDDEN,
2751 Html(
2752 template
2753 .render()
2754 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2755 ),
2756 )
2757 .into_response());
2758 }
2759 Ok(())
2760}
2761
2762fn apply_output_dir_exclusions(
2764 config: &mut sloc_config::AppConfig,
2765 project_path: &str,
2766 raw_output_dir: &str,
2767) {
2768 let project_root = resolve_input_path(project_path);
2769 let raw_out = raw_output_dir.trim();
2770 let resolved_out = if raw_out.is_empty() {
2771 project_root.join("sloc")
2772 } else if Path::new(raw_out).is_absolute() {
2773 PathBuf::from(raw_out)
2774 } else {
2775 workspace_root().join(raw_out)
2776 };
2777 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2778 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2779 let dir = first.to_string();
2780 if !config.discovery.excluded_directories.contains(&dir) {
2781 config.discovery.excluded_directories.push(dir);
2782 }
2783 }
2784 }
2785 if !config
2786 .discovery
2787 .excluded_directories
2788 .iter()
2789 .any(|d| d == "sloc")
2790 {
2791 config
2792 .discovery
2793 .excluded_directories
2794 .push("sloc".to_string());
2795 }
2796}
2797
2798const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2800 ScanSummarySnapshot {
2801 files_analyzed: run.summary_totals.files_analyzed,
2802 files_skipped: run.summary_totals.files_skipped,
2803 total_physical_lines: run.summary_totals.total_physical_lines,
2804 code_lines: run.summary_totals.code_lines,
2805 comment_lines: run.summary_totals.comment_lines,
2806 blank_lines: run.summary_totals.blank_lines,
2807 functions: run.summary_totals.functions,
2808 classes: run.summary_totals.classes,
2809 variables: run.summary_totals.variables,
2810 imports: run.summary_totals.imports,
2811 test_count: run.summary_totals.test_count,
2812 }
2813}
2814
2815pub(crate) fn build_run_registry_entry(
2817 run: &AnalysisRun,
2818 run_id: &str,
2819 project_label: &str,
2820 artifacts: &RunArtifacts,
2821) -> RegistryEntry {
2822 RegistryEntry {
2823 run_id: run_id.to_owned(),
2824 timestamp_utc: run.tool.timestamp_utc,
2825 project_label: project_label.to_owned(),
2826 input_roots: run.input_roots.clone(),
2827 json_path: artifacts.json_path.clone(),
2828 html_path: artifacts.html_path.clone(),
2829 pdf_path: artifacts.pdf_path.clone(),
2830 summary: summary_snapshot_from_run(run),
2831 git_branch: run.git_branch.clone(),
2832 git_commit: run.git_commit_short.clone(),
2833 git_author: run.git_commit_author.clone(),
2834 git_tags: run.git_tags.clone(),
2835 git_nearest_tag: run.git_nearest_tag.clone(),
2836 git_commit_date: run.git_commit_date.clone(),
2837 }
2838}
2839
2840fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2842 if let Some(policy) = form.mixed_line_policy {
2843 config.analysis.mixed_line_policy = policy;
2844 }
2845 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2846 config.analysis.generated_file_detection =
2847 form.generated_file_detection.as_deref() != Some("disabled");
2848 config.analysis.minified_file_detection =
2849 form.minified_file_detection.as_deref() != Some("disabled");
2850 config.analysis.vendor_directory_detection =
2851 form.vendor_directory_detection.as_deref() != Some("disabled");
2852 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2853 if let Some(binary_behavior) = form.binary_file_behavior {
2854 config.analysis.binary_file_behavior = binary_behavior;
2855 }
2856 if let Some(report_title) = form.report_title.as_deref() {
2857 let trimmed = report_title.trim();
2858 if !trimmed.is_empty() {
2859 config.reporting.report_title = trimmed.to_string();
2860 }
2861 }
2862 if let Some(hf) = form.report_header_footer.as_deref() {
2863 let trimmed = hf.trim();
2864 config.reporting.report_header_footer = if trimmed.is_empty() {
2865 None
2866 } else {
2867 Some(trimmed.to_string())
2868 };
2869 }
2870 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2871 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2872 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2873 if let Some(cov) = &form.coverage_file {
2874 let trimmed = cov.trim();
2875 if !trimmed.is_empty() {
2876 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2877 }
2878 }
2879}
2880
2881fn spawn_pdf_background(pending_pdf: PendingPdf) {
2883 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2884 tokio::spawn(async move {
2885 let result = tokio::task::spawn_blocking(move || {
2886 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2887 if cleanup_src {
2888 let _ = fs::remove_file(&pdf_src);
2889 }
2890 r
2891 })
2892 .await;
2893 match result {
2894 Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
2895 Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
2896 Ok(Ok(())) => {}
2897 }
2898 });
2899 }
2900}
2901
2902fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2904 cmp.file_deltas
2905 .iter()
2906 .map(|f| match f.status {
2907 FileChangeStatus::Added => f.current_code,
2908 FileChangeStatus::Modified => f.code_delta.max(0),
2909 _ => 0,
2910 })
2911 .sum()
2912}
2913
2914fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2916 cmp.file_deltas
2917 .iter()
2918 .map(|f| match f.status {
2919 FileChangeStatus::Removed => f.baseline_code,
2920 FileChangeStatus::Modified => (-f.code_delta).max(0),
2921 _ => 0,
2922 })
2923 .sum()
2924}
2925
2926fn build_submodule_row(
2928 s: &sloc_core::SubmoduleSummary,
2929 run: &AnalysisRun,
2930 run_id: &str,
2931 run_dir: &Path,
2932 generate_html: bool,
2933) -> SubmoduleRow {
2934 let safe = sanitize_project_label(&s.name);
2935 let artifact_key = format!("sub_{safe}");
2936 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2937 let parent_path = run
2938 .input_roots
2939 .first()
2940 .map_or("", std::string::String::as_str);
2941 let sub_run = build_sub_run(run, s, parent_path);
2942 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2943 let path = run_dir.join(format!("{artifact_key}.html"));
2944 if fs::write(&path, sub_html.as_bytes()).is_ok() {
2945 Some(format!("/runs/{artifact_key}/{run_id}"))
2946 } else {
2947 None
2948 }
2949 })
2950 } else {
2951 None
2952 };
2953 SubmoduleRow {
2954 name: s.name.clone(),
2955 relative_path: s.relative_path.clone(),
2956 files_analyzed: s.files_analyzed,
2957 code_lines: s.code_lines,
2958 comment_lines: s.comment_lines,
2959 blank_lines: s.blank_lines,
2960 total_physical_lines: s.total_physical_lines,
2961 html_url,
2962 }
2963}
2964
2965#[allow(clippy::too_many_lines)]
2968#[allow(clippy::similar_names)]
2969async fn analyze_handler(
2970 State(state): State<AppState>,
2972 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2973 Form(form): Form<AnalyzeForm>,
2974) -> impl IntoResponse {
2975 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2976 let template = ErrorTemplate {
2977 message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2978 .to_string(),
2979 last_report_url: None,
2980 last_report_label: None,
2981 csp_nonce: csp_nonce.clone(),
2982 };
2983 return (
2984 StatusCode::SERVICE_UNAVAILABLE,
2985 Html(
2986 template
2987 .render()
2988 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2989 ),
2990 )
2991 .into_response();
2992 };
2993
2994 let mut config = state.base_config.clone();
2995
2996 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2997 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2998 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2999
3000 if !is_git_mode {
3001 let resolved_path = resolve_input_path(&form.path);
3002 if state.server_mode {
3003 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3004 return resp;
3005 }
3006 }
3007 config.discovery.root_paths = vec![resolved_path];
3008 }
3009
3010 apply_form_to_config(&mut config, &form);
3011 apply_output_dir_exclusions(
3012 &mut config,
3013 &form.path,
3014 form.output_dir.as_deref().unwrap_or(""),
3015 );
3016
3017 let wait_id = uuid::Uuid::new_v4().to_string();
3019 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3020
3021 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3023
3024 let project_path_bg = form.path.clone();
3026 let output_dir_bg = form.output_dir.clone();
3027 let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
3028 let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
3029 let generate_html_bg = form.generate_html.is_some();
3030 let generate_pdf_bg = form.generate_pdf.is_some();
3031 let clones_dir = state.git_clones_dir.clone();
3032 let wait_id_bg = wait_id.clone();
3033 let state_bg = state.clone();
3034 let cancel_bg = Arc::clone(&cancel_token);
3035
3036 {
3037 let mut runs = state.async_runs.lock().await;
3038 runs.insert(
3039 wait_id.clone(),
3040 AsyncRunState::Running {
3041 started_at: std::time::Instant::now(),
3042 cancel_token,
3043 },
3044 );
3045 }
3046
3047 tokio::spawn(async move {
3048 let _permit = sem_permit;
3050
3051 let git_repo_sb = git_repo_bg.clone();
3053 let git_ref_sb = git_ref_bg.clone();
3054 let cancel_sb = Arc::clone(&cancel_bg);
3055 let analysis_result =
3056 tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
3057 if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
3058 let dest = git_clone_dest(repo, &clones_dir);
3059 sloc_git::clone_or_fetch(repo, &dest)?;
3060 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3061 sloc_git::create_worktree(&dest, refname, &wt)?;
3062 config.discovery.root_paths = vec![wt.clone()];
3063 let run = analyze(&config, "serve", Some(&cancel_sb));
3064 let _ = sloc_git::destroy_worktree(&dest, &wt);
3065 let mut run = run?;
3066 if run.git_branch.is_none() {
3067 run.git_branch = Some(refname.clone());
3068 }
3069 let html = render_html(&run)?;
3070 return Ok((run, html));
3071 }
3072 let run = analyze(&config, "serve", Some(&cancel_sb))?;
3073 let html = render_html(&run)?;
3074 Ok((run, html))
3075 })
3076 .await
3077 .map_err(|err| anyhow::anyhow!(err.to_string()))
3078 .and_then(|result| result);
3079
3080 if cancel_bg.load(std::sync::atomic::Ordering::Relaxed) {
3082 let mut runs = state_bg.async_runs.lock().await;
3083 if matches!(
3085 runs.get(&wait_id_bg),
3086 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3087 ) {
3088 runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3089 }
3090 drop(runs);
3091 return;
3092 }
3093
3094 let (run, report_html) = match analysis_result {
3095 Ok(v) => v,
3096 Err(err) => {
3097 let message = if err.to_string().contains("analysis cancelled") {
3099 let mut runs = state_bg.async_runs.lock().await;
3100 runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3101 drop(runs);
3102 return;
3103 } else {
3104 "Analysis failed. Check that the path exists and is readable.".to_string()
3105 };
3106 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3107 let mut runs = state_bg.async_runs.lock().await;
3108 runs.insert(wait_id_bg.clone(), AsyncRunState::Failed { message });
3109 drop(runs);
3110 return;
3111 }
3112 };
3113
3114 let run_id = run.tool.run_id.clone();
3115 tracing::info!(event = "scan_complete", run_id = %run_id,
3116 path = %project_path_bg, files = run.summary_totals.files_analyzed,
3117 "Analysis finished");
3118
3119 let prev_entry: Option<RegistryEntry> = {
3120 let reg = state_bg.registry.lock().await;
3121 reg.entries_for_roots(&run.input_roots)
3122 .into_iter()
3123 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3124 .cloned()
3125 };
3126
3127 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3128 prev.json_path
3129 .as_ref()
3130 .and_then(|p| read_json(p).ok())
3131 .map(|prev_run| compute_delta(&prev_run, &run))
3132 });
3133 let prev_scan_count: usize = {
3134 let reg = state_bg.registry.lock().await;
3135 reg.entries_for_roots(&run.input_roots)
3136 .iter()
3137 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3138 .count()
3139 };
3140
3141 let output_root = resolve_output_root(output_dir_bg.as_deref());
3142
3143 let project_label = if let (Some(repo), Some(refname)) = (
3144 git_repo_bg.as_deref().filter(|s| !s.is_empty()),
3145 git_ref_bg.as_deref().filter(|s| !s.is_empty()),
3146 ) {
3147 let repo_name = repo
3148 .trim_end_matches('/')
3149 .trim_end_matches(".git")
3150 .rsplit('/')
3151 .next()
3152 .unwrap_or("repo");
3153 sanitize_project_label(&format!("{repo_name}_{refname}"))
3154 } else {
3155 sanitize_project_label(&project_path_bg)
3156 };
3157 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3158 let file_stem = {
3159 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
3160 if commit.is_empty() {
3161 project_label.clone()
3162 } else {
3163 format!("{project_label}_{commit}")
3164 }
3165 };
3166
3167 let result_context = RunResultContext {
3168 prev_entry: prev_entry.clone(),
3169 prev_scan_count,
3170 project_path: project_path_bg.clone(),
3171 };
3172
3173 let artifact_result = persist_run_artifacts(
3174 &run,
3175 &report_html,
3176 &run_dir,
3177 true,
3178 generate_html_bg,
3179 generate_pdf_bg,
3180 &run.effective_configuration.reporting.report_title,
3181 &file_stem,
3182 result_context,
3183 );
3184
3185 let (artifacts, pending_pdf) = match artifact_result {
3186 Ok(v) => v,
3187 Err(err) => {
3188 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3189 let mut runs = state_bg.async_runs.lock().await;
3190 runs.insert(
3191 wait_id_bg.clone(),
3192 AsyncRunState::Failed {
3193 message: "Failed to save report artifacts. Check available disk space."
3194 .to_string(),
3195 },
3196 );
3197 drop(runs);
3198 return;
3199 }
3200 };
3201
3202 {
3203 let mut map = state_bg.artifacts.lock().await;
3204 map.insert(run_id.clone(), artifacts.clone());
3205 }
3206
3207 {
3208 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3209 let mut reg = state_bg.registry.lock().await;
3210 reg.add_entry(entry);
3211 let _ = reg.save(&state_bg.registry_path);
3212 }
3213
3214 if let Some(ref cfg_path) = artifacts.scan_config_path {
3215 let policy_str =
3216 serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3217 .ok()
3218 .and_then(|v| v.as_str().map(String::from))
3219 .unwrap_or_else(|| "code_only".to_string());
3220 let behavior_str =
3221 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3222 .ok()
3223 .and_then(|v| v.as_str().map(String::from))
3224 .unwrap_or_else(|| "skip".to_string());
3225 let scan_cfg = ScanConfig {
3226 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3227 path: project_path_bg.clone(),
3228 include_globs: run
3229 .effective_configuration
3230 .discovery
3231 .include_globs
3232 .join("\n"),
3233 exclude_globs: run
3234 .effective_configuration
3235 .discovery
3236 .exclude_globs
3237 .join("\n"),
3238 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3239 mixed_line_policy: policy_str,
3240 python_docstrings_as_comments: run
3241 .effective_configuration
3242 .analysis
3243 .python_docstrings_as_comments,
3244 generated_file_detection: run
3245 .effective_configuration
3246 .analysis
3247 .generated_file_detection,
3248 minified_file_detection: run
3249 .effective_configuration
3250 .analysis
3251 .minified_file_detection,
3252 vendor_directory_detection: run
3253 .effective_configuration
3254 .analysis
3255 .vendor_directory_detection,
3256 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3257 binary_file_behavior: behavior_str,
3258 output_dir: output_dir_bg.clone().unwrap_or_default(),
3259 report_title: run.effective_configuration.reporting.report_title.clone(),
3260 generate_html: generate_html_bg,
3261 generate_pdf: generate_pdf_bg,
3262 };
3263 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3264 let _ = std::fs::write(cfg_path, json);
3265 }
3266 }
3267
3268 spawn_pdf_background(pending_pdf);
3269
3270 let mut runs = state_bg.async_runs.lock().await;
3272 runs.insert(
3273 wait_id_bg.clone(),
3274 AsyncRunState::Complete {
3275 run_id: run_id.clone(),
3276 },
3277 );
3278 drop(runs);
3279
3280 let _ = scan_delta;
3282 });
3283
3284 let template = ScanWaitTemplate {
3285 version: env!("CARGO_PKG_VERSION"),
3286 wait_id_json,
3287 project_path: form.path.clone(),
3288 csp_nonce,
3289 };
3290 let html = template
3291 .render()
3292 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3293 let mut response = Html(html).into_response();
3294 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3295 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3296 response.headers_mut().insert(name, val);
3297 }
3298 }
3299 response
3300}
3301
3302#[derive(Serialize)]
3305#[serde(tag = "state", rename_all = "snake_case")]
3306enum AsyncRunStatusResponse {
3307 Running { elapsed_secs: u64 },
3308 Complete { run_id: String },
3309 Failed { message: String },
3310 Cancelled,
3311}
3312
3313async fn async_run_status_handler(
3314 State(state): State<AppState>,
3315 AxumPath(wait_id): AxumPath<String>,
3316) -> Response {
3317 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3319 return StatusCode::BAD_REQUEST.into_response();
3320 }
3321 let run_state = {
3322 let runs = state.async_runs.lock().await;
3323 runs.get(&wait_id).cloned()
3324 };
3325 match run_state {
3326 None => StatusCode::NOT_FOUND.into_response(),
3327 Some(AsyncRunState::Running { started_at, .. }) => {
3328 if started_at.elapsed() > std::time::Duration::from_hours(2) {
3330 let mut runs = state.async_runs.lock().await;
3331 runs.insert(
3332 wait_id,
3333 AsyncRunState::Failed {
3334 message: "Analysis timed out after 2 hours.".to_string(),
3335 },
3336 );
3337 drop(runs);
3338 return Json(AsyncRunStatusResponse::Failed {
3339 message: "Analysis timed out after 2 hours.".to_string(),
3340 })
3341 .into_response();
3342 }
3343 Json(AsyncRunStatusResponse::Running {
3344 elapsed_secs: started_at.elapsed().as_secs(),
3345 })
3346 .into_response()
3347 }
3348 Some(AsyncRunState::Complete { run_id }) => {
3349 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3350 }
3351 Some(AsyncRunState::Failed { message }) => {
3352 Json(AsyncRunStatusResponse::Failed { message }).into_response()
3353 }
3354 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3355 }
3356}
3357
3358async fn cancel_run_handler(
3359 State(state): State<AppState>,
3360 AxumPath(wait_id): AxumPath<String>,
3361) -> Response {
3362 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3363 return StatusCode::BAD_REQUEST.into_response();
3364 }
3365 let mut runs = state.async_runs.lock().await;
3366 let resp = match runs.get(&wait_id) {
3367 Some(AsyncRunState::Running { cancel_token, .. }) => {
3368 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3369 runs.insert(wait_id, AsyncRunState::Cancelled);
3370 StatusCode::OK.into_response()
3371 }
3372 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3373 _ => StatusCode::NOT_FOUND.into_response(),
3374 };
3375 drop(runs);
3376 resp
3377}
3378
3379async fn async_run_result_handler(
3380 State(state): State<AppState>,
3381 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3382 AxumPath(run_id): AxumPath<String>,
3383) -> Response {
3384 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3385 return StatusCode::BAD_REQUEST.into_response();
3386 }
3387
3388 let artifacts = {
3389 let map = state.artifacts.lock().await;
3390 map.get(&run_id).cloned()
3391 };
3392 let artifacts = if let Some(a) = artifacts {
3393 a
3394 } else {
3395 let reg = state.registry.lock().await;
3396 if let Some(entry) = reg.find_by_run_id(&run_id) {
3397 recover_artifacts_from_registry(entry)
3398 } else {
3399 let html = ErrorTemplate {
3400 message: format!(
3401 "Report not found. Run ID {} is not in the scan history.",
3402 &run_id[..run_id.len().min(8)]
3403 ),
3404 last_report_url: Some("/view-reports".to_string()),
3405 last_report_label: Some("View Reports".to_string()),
3406 csp_nonce: csp_nonce.clone(),
3407 }
3408 .render()
3409 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3410 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3411 }
3412 };
3413
3414 let json_path = if let Some(p) = &artifacts.json_path {
3415 p.clone()
3416 } else {
3417 let html = ErrorTemplate {
3418 message: "JSON result was not saved for this run.".to_string(),
3419 last_report_url: Some("/view-reports".to_string()),
3420 last_report_label: Some("View Reports".to_string()),
3421 csp_nonce: csp_nonce.clone(),
3422 }
3423 .render()
3424 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3425 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3426 };
3427
3428 let Ok(run) = read_json(&json_path) else {
3429 let folder_hint = json_path
3430 .parent()
3431 .map(|p| p.display().to_string())
3432 .unwrap_or_default();
3433 let redirect_url = format!("/runs/result/{run_id}");
3434 return missing_scan_relocate_response(
3435 &format!(
3436 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
3437 deleted. Browse to the folder containing your scan output to reconnect it.",
3438 json_path.display()
3439 ),
3440 &run_id,
3441 &folder_hint,
3442 &redirect_url,
3443 state.server_mode,
3444 &csp_nonce,
3445 );
3446 };
3447
3448 let confluence_configured = {
3449 let store = state.confluence.lock().await;
3450 store.is_configured()
3451 };
3452
3453 render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3454}
3455
3456#[allow(clippy::too_many_lines)]
3457#[allow(clippy::similar_names)] fn render_result_page(
3459 run: &AnalysisRun,
3461 artifacts: &RunArtifacts,
3462 run_id: &str,
3463 csp_nonce: &str,
3464 confluence_configured: bool,
3465) -> Response {
3466 let ctx = &artifacts.result_context;
3467 let prev_entry = &ctx.prev_entry;
3468 let prev_scan_count = ctx.prev_scan_count;
3469 let project_path = &ctx.project_path;
3470
3471 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3472 prev.json_path
3473 .as_ref()
3474 .and_then(|p| read_json(p).ok())
3475 .map(|prev_run| compute_delta(&prev_run, run))
3476 });
3477
3478 let files_analyzed = run.per_file_records.len() as u64;
3479 let files_skipped = run.skipped_file_records.len() as u64;
3480 let physical_lines = run
3481 .totals_by_language
3482 .iter()
3483 .map(|r| r.total_physical_lines)
3484 .sum::<u64>();
3485 let code_lines = run
3486 .totals_by_language
3487 .iter()
3488 .map(|r| r.code_lines)
3489 .sum::<u64>();
3490 let comment_lines = run
3491 .totals_by_language
3492 .iter()
3493 .map(|r| r.comment_lines)
3494 .sum::<u64>();
3495 let blank_lines = run
3496 .totals_by_language
3497 .iter()
3498 .map(|r| r.blank_lines)
3499 .sum::<u64>();
3500 let mixed_lines = run
3501 .totals_by_language
3502 .iter()
3503 .map(|r| r.mixed_lines_separate)
3504 .sum::<u64>();
3505 let functions = run
3506 .totals_by_language
3507 .iter()
3508 .map(|r| r.functions)
3509 .sum::<u64>();
3510 let classes = run
3511 .totals_by_language
3512 .iter()
3513 .map(|r| r.classes)
3514 .sum::<u64>();
3515 let variables = run
3516 .totals_by_language
3517 .iter()
3518 .map(|r| r.variables)
3519 .sum::<u64>();
3520 let imports = run
3521 .totals_by_language
3522 .iter()
3523 .map(|r| r.imports)
3524 .sum::<u64>();
3525
3526 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3527 let prev_fa = prev_sum.map(|s| s.files_analyzed);
3528 let prev_fs = prev_sum.map(|s| s.files_skipped);
3529 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3530 let prev_cl = prev_sum.map(|s| s.code_lines);
3531 let prev_cml = prev_sum.map(|s| s.comment_lines);
3532 let prev_bl = prev_sum.map(|s| s.blank_lines);
3533 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3534 let prev_fa_str = fmt_prev(prev_fa);
3535 let prev_fs_str = fmt_prev(prev_fs);
3536 let prev_pl_str = fmt_prev(prev_pl);
3537 let prev_cl_str = fmt_prev(prev_cl);
3538 let prev_cml_str = fmt_prev(prev_cml);
3539 let prev_bl_str = fmt_prev(prev_bl);
3540 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3541 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3542 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3543 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3544 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3545 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3546 let delta_fa_class = delta_fa_class.to_string();
3547 let delta_fs_class = delta_fs_class.to_string();
3548 let delta_pl_class = delta_pl_class.to_string();
3549 let delta_cl_class = delta_cl_class.to_string();
3550 let delta_cml_class = delta_cml_class.to_string();
3551 let delta_bl_class = delta_bl_class.to_string();
3552
3553 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3554 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3555 let (delta_lines_net_str, delta_lines_net_class) =
3556 match (delta_lines_added, delta_lines_removed) {
3557 (Some(a), Some(r)) => {
3558 let net = a - r;
3559 (fmt_delta(net), delta_class(net).to_string())
3560 }
3561 _ => ("—".to_string(), "na".to_string()),
3562 };
3563
3564 let run_dir = artifacts.output_dir.clone();
3565 let git_branch = run.git_branch.clone();
3566 let git_commit = run.git_commit_short.clone();
3567 let git_author = run.git_commit_author.clone();
3568
3569 let template = ResultTemplate {
3570 version: env!("CARGO_PKG_VERSION"),
3571 report_title: run.effective_configuration.reporting.report_title.clone(),
3572 project_path: project_path.clone(),
3573 output_dir: display_path(&artifacts.output_dir),
3574 run_id: run_id.to_owned(),
3575 files_analyzed,
3576 files_skipped,
3577 physical_lines,
3578 code_lines,
3579 comment_lines,
3580 blank_lines,
3581 mixed_lines,
3582 functions,
3583 classes,
3584 variables,
3585 imports,
3586 html_url: artifacts
3587 .html_path
3588 .as_ref()
3589 .map(|_| format!("/runs/html/{run_id}")),
3590 pdf_url: artifacts
3591 .pdf_path
3592 .as_ref()
3593 .map(|_| format!("/runs/pdf/{run_id}")),
3594 json_url: artifacts
3595 .json_path
3596 .as_ref()
3597 .map(|_| format!("/runs/json/{run_id}")),
3598 html_download_url: artifacts
3599 .html_path
3600 .as_ref()
3601 .map(|_| format!("/runs/html/{run_id}?download=1")),
3602 pdf_download_url: artifacts
3603 .pdf_path
3604 .as_ref()
3605 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3606 json_download_url: artifacts
3607 .json_path
3608 .as_ref()
3609 .map(|_| format!("/runs/json/{run_id}?download=1")),
3610 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3611 pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3612 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3613 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3614 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3615 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3616 prev_fa_str,
3617 prev_fs_str,
3618 prev_pl_str,
3619 prev_cl_str,
3620 prev_cml_str,
3621 prev_bl_str,
3622 delta_fa_str,
3623 delta_fa_class,
3624 delta_fs_str,
3625 delta_fs_class,
3626 delta_pl_str,
3627 delta_pl_class,
3628 delta_cl_str,
3629 delta_cl_class,
3630 delta_cml_str,
3631 delta_cml_class,
3632 delta_bl_str,
3633 delta_bl_class,
3634 delta_lines_added,
3635 delta_lines_removed,
3636 delta_lines_net_str,
3637 delta_lines_net_class,
3638 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3639 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3640 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3641 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3642 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3643 d.file_deltas
3644 .iter()
3645 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3646 .map(|f| {
3647 #[allow(clippy::cast_sign_loss)]
3648 let n = f.current_code as u64;
3649 n
3650 })
3651 .sum()
3652 }),
3653 git_branch,
3654 git_commit,
3655 git_author,
3656 current_scan_number: prev_scan_count + 1,
3657 prev_scan_count,
3658 submodule_rows: run
3659 .submodule_summaries
3660 .iter()
3661 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3662 .collect(),
3663 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3664 scan_config_url: format!("/runs/scan-config/{run_id}"),
3665 lang_chart_json: {
3666 let entries: Vec<String> = run
3667 .totals_by_language
3668 .iter()
3669 .take(12)
3670 .map(|l| {
3671 let name = l
3672 .language
3673 .display_name()
3674 .replace('\\', "\\\\")
3675 .replace('"', "\\\"");
3676 format!(
3677 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3678 name,
3679 l.code_lines,
3680 l.comment_lines,
3681 l.blank_lines,
3682 l.functions,
3683 l.classes,
3684 l.variables,
3685 l.imports,
3686 l.files,
3687 )
3688 })
3689 .collect();
3690 format!("[{}]", entries.join(","))
3691 },
3692 scatter_chart_json: {
3693 let entries: Vec<String> = run
3694 .totals_by_language
3695 .iter()
3696 .map(|l| {
3697 let name = l
3698 .language
3699 .display_name()
3700 .replace('\\', "\\\\")
3701 .replace('"', "\\\"");
3702 format!(
3703 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3704 name, l.files, l.code_lines, l.total_physical_lines,
3705 )
3706 })
3707 .collect();
3708 format!("[{}]", entries.join(","))
3709 },
3710 semantic_chart_json: {
3711 let entries: Vec<String> = run
3712 .totals_by_language
3713 .iter()
3714 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3715 .map(|l| {
3716 let name = l
3717 .language
3718 .display_name()
3719 .replace('\\', "\\\\")
3720 .replace('"', "\\\"");
3721 format!(
3722 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3723 name, l.functions, l.classes, l.variables, l.imports,
3724 )
3725 })
3726 .collect();
3727 format!("[{}]", entries.join(","))
3728 },
3729 submodule_chart_json: {
3730 let entries: Vec<String> = run
3731 .submodule_summaries
3732 .iter()
3733 .map(|s| {
3734 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3735 format!(
3736 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3737 name,
3738 s.code_lines,
3739 s.comment_lines,
3740 s.blank_lines,
3741 s.total_physical_lines,
3742 s.files_analyzed,
3743 )
3744 })
3745 .collect();
3746 format!("[{}]", entries.join(","))
3747 },
3748 has_submodule_data: !run.submodule_summaries.is_empty(),
3749 has_semantic_data: run
3750 .totals_by_language
3751 .iter()
3752 .any(|l| l.functions > 0 || l.classes > 0),
3753 csp_nonce: csp_nonce.to_owned(),
3754 confluence_configured,
3755 report_header_footer: run
3756 .effective_configuration
3757 .reporting
3758 .report_header_footer
3759 .clone(),
3760 };
3761
3762 Html(
3763 template
3764 .render()
3765 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3766 )
3767 .into_response()
3768}
3769
3770fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3771 let slug: String = report_title
3772 .chars()
3773 .map(|c| {
3774 if c.is_alphanumeric() || c == '-' {
3775 c.to_ascii_lowercase()
3776 } else {
3777 '_'
3778 }
3779 })
3780 .collect::<String>()
3781 .split('_')
3782 .filter(|s| !s.is_empty())
3783 .collect::<Vec<_>>()
3784 .join("_");
3785
3786 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3787
3788 if slug.is_empty() {
3789 format!("report_{short_id}.pdf")
3790 } else {
3791 format!("{slug}_{short_id}.pdf")
3792 }
3793}
3794
3795async fn pdf_status_handler(
3798 State(state): State<AppState>,
3799 AxumPath(run_id): AxumPath<String>,
3800) -> Response {
3801 let pdf_path = {
3802 let registry = state.artifacts.lock().await;
3803 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3804 };
3805 let pdf_path = if pdf_path.is_some() {
3806 pdf_path
3807 } else {
3808 let reg = state.registry.lock().await;
3809 reg.find_by_run_id(&run_id)
3810 .map(recover_artifacts_from_registry)
3811 .and_then(|a| a.pdf_path)
3812 };
3813 let ready = pdf_path.is_some_and(|p| p.exists());
3814 Json(serde_json::json!({"ready": ready})).into_response()
3815}
3816
3817fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3822 let Some(start) = html.find("nonce=\"") else {
3824 return html
3828 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3829 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3830 };
3831 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
3833 return html.to_owned();
3834 };
3835 let old_nonce = &html[value_start..value_start + end_offset];
3836 html.replace(
3837 &format!("nonce=\"{old_nonce}\""),
3838 &format!("nonce=\"{new_nonce}\""),
3839 )
3840}
3841
3842fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3843 match fs::read_to_string(path) {
3844 Ok(raw) => {
3845 let content = patch_html_nonce(&raw, csp_nonce);
3847 if wants_download {
3848 (
3849 [
3850 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3851 (
3852 header::CONTENT_DISPOSITION,
3853 "attachment; filename=report.html",
3854 ),
3855 ],
3856 content,
3857 )
3858 .into_response()
3859 } else {
3860 Html(content).into_response()
3861 }
3862 }
3863 Err(err) => {
3864 let filename = path.file_name().map_or_else(
3865 || "report.html".to_string(),
3866 |n| n.to_string_lossy().into_owned(),
3867 );
3868 let msg = format!(
3869 "HTML report '{filename}' could not be read.\n\n\
3870 Error: {err}\n\n\
3871 If you moved or renamed the output folder, the stored path is now stale. \
3872 Use 'Open HTML folder' from the results page to browse the output directory."
3873 );
3874 let html = ErrorTemplate {
3875 message: msg,
3876 last_report_url: Some("/view-reports".to_string()),
3877 last_report_label: Some("View Reports".to_string()),
3878 csp_nonce: csp_nonce.to_owned(),
3879 }
3880 .render()
3881 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3882 (StatusCode::NOT_FOUND, Html(html)).into_response()
3883 }
3884 }
3885}
3886
3887fn serve_pdf_artifact(
3889 path: &Path,
3890 report_title: &str,
3891 run_id: &str,
3892 wants_download: bool,
3893 csp_nonce: &str,
3894) -> Response {
3895 match fs::read(path) {
3896 Ok(bytes) => {
3897 let filename = build_pdf_filename(report_title, run_id);
3898 let disposition = if wants_download {
3899 format!("attachment; filename=\"{filename}\"")
3900 } else {
3901 format!("inline; filename=\"{filename}\"")
3902 };
3903 (
3904 [
3905 (header::CONTENT_TYPE, "application/pdf".to_string()),
3906 (header::CONTENT_DISPOSITION, disposition),
3907 ],
3908 bytes,
3909 )
3910 .into_response()
3911 }
3912 Err(err) => {
3913 let filename = path.file_name().map_or_else(
3914 || "report.pdf".to_string(),
3915 |n| n.to_string_lossy().into_owned(),
3916 );
3917 let msg = format!(
3918 "PDF report '{filename}' could not be read.\n\n\
3919 Error: {err}\n\n\
3920 If you moved or renamed the output folder, the stored path is now stale. \
3921 Use 'Open PDF folder' from the results page to browse the output directory."
3922 );
3923 let html = ErrorTemplate {
3924 message: msg,
3925 last_report_url: Some("/view-reports".to_string()),
3926 last_report_label: Some("View Reports".to_string()),
3927 csp_nonce: csp_nonce.to_owned(),
3928 }
3929 .render()
3930 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3931 (StatusCode::NOT_FOUND, Html(html)).into_response()
3932 }
3933 }
3934}
3935
3936fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3938 match fs::read(path) {
3939 Ok(bytes) => {
3940 if wants_download {
3941 (
3942 [
3943 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3944 (
3945 header::CONTENT_DISPOSITION,
3946 "attachment; filename=result.json",
3947 ),
3948 ],
3949 bytes,
3950 )
3951 .into_response()
3952 } else {
3953 (
3954 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3955 bytes,
3956 )
3957 .into_response()
3958 }
3959 }
3960 Err(err) => {
3961 let filename = path.file_name().map_or_else(
3962 || "result.json".to_string(),
3963 |n| n.to_string_lossy().into_owned(),
3964 );
3965 let msg = format!(
3966 "JSON result '{filename}' could not be read.\n\n\
3967 Error: {err}\n\n\
3968 If you moved or renamed the output folder, the stored path is now stale. \
3969 Use 'Open JSON folder' from the results page to browse the output directory."
3970 );
3971 let html = ErrorTemplate {
3972 message: msg,
3973 last_report_url: Some("/view-reports".to_string()),
3974 last_report_label: Some("View Reports".to_string()),
3975 csp_nonce: csp_nonce.to_owned(),
3976 }
3977 .render()
3978 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3979 (StatusCode::NOT_FOUND, Html(html)).into_response()
3980 }
3981 }
3982}
3983
3984fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3986 let output_dir = entry
3987 .html_path
3988 .as_ref()
3989 .or(entry.json_path.as_ref())
3990 .or(entry.pdf_path.as_ref())
3991 .and_then(|p| p.parent().map(PathBuf::from))
3992 .unwrap_or_default();
3993 let pdf_path = entry.pdf_path.clone().or_else(|| {
3996 let candidate = output_dir.join("report.pdf");
3997 candidate.exists().then_some(candidate)
3998 });
3999 RunArtifacts {
4000 output_dir: output_dir.clone(),
4001 html_path: entry.html_path.clone(),
4002 pdf_path,
4003 json_path: entry.json_path.clone(),
4004 scan_config_path: find_scan_config_in_dir(&output_dir),
4005 report_title: entry.project_label.clone(),
4006 result_context: RunResultContext::default(),
4007 }
4008}
4009
4010#[allow(clippy::too_many_lines)]
4011async fn artifact_handler(
4012 State(state): State<AppState>,
4014 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4015 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4016 Query(query): Query<ArtifactQuery>,
4017) -> Response {
4018 let artifact_set = {
4019 let registry = state.artifacts.lock().await;
4020 registry.get(&run_id).cloned()
4021 };
4022
4023 let artifact_set = if let Some(a) = artifact_set {
4026 a
4027 } else {
4028 let reg = state.registry.lock().await;
4029 if let Some(entry) = reg.find_by_run_id(&run_id) {
4030 recover_artifacts_from_registry(entry)
4031 } else {
4032 let short_id = &run_id[..run_id.len().min(8)];
4033 let hint = if matches!(run_id.as_str(), "pdf" | "html" | "json" | "scan-config") {
4034 format!(
4035 " The URL format appears to be reversed — \
4036 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4037 Use the View Reports page to navigate to your scan."
4038 )
4039 } else {
4040 " The report may have been deleted or the report directory moved. \
4041 Use View Reports to browse your scan history."
4042 .to_string()
4043 };
4044 let error_html = ErrorTemplate {
4045 message: format!(
4046 "Report not found. \"{short_id}\" is not a recognized run ID.{hint}"
4047 ),
4048 last_report_url: Some("/view-reports".to_string()),
4049 last_report_label: Some("View Reports".to_string()),
4050 csp_nonce: csp_nonce.clone(),
4051 }
4052 .render()
4053 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4054 return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
4055 }
4056 };
4057
4058 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4059
4060 match artifact.as_str() {
4061 "html" => {
4062 let Some(path) = artifact_set.html_path else {
4063 return StatusCode::NOT_FOUND.into_response();
4064 };
4065 serve_html_artifact(&path, wants_download, &csp_nonce)
4066 }
4067 "pdf" => {
4068 let Some(path) = artifact_set.pdf_path else {
4069 let msg = "PDF report was not generated for this run, or was not recorded in \
4070 the scan registry. Re-run the analysis with PDF output enabled."
4071 .to_string();
4072 let html = ErrorTemplate {
4073 message: msg,
4074 last_report_url: Some(format!("/runs/html/{run_id}")),
4075 last_report_label: Some("View HTML Report".to_string()),
4076 csp_nonce: csp_nonce.clone(),
4077 }
4078 .render()
4079 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4080 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4081 };
4082 if !path.exists() {
4085 let html = format!(
4086 "<!doctype html><html lang=\"en\"><head>\
4087 <meta charset=utf-8>\
4088 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4089 <meta http-equiv=\"refresh\" content=\"5\">\
4090 <title>OxideSLOC | Generating PDF\u{2026}</title>\
4091 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4092 <style nonce=\"{csp_nonce}\">\
4093 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4094 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4095 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4096 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4097 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4098 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4099 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4100 background:var(--bg);color:var(--text);}}\
4101 .top-nav{{position:sticky;top:0;z-index:30;\
4102 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4103 border-bottom:1px solid rgba(255,255,255,0.12);\
4104 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4105 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4106 min-height:56px;display:flex;align-items:center;gap:14px;}}\
4107 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4108 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4109 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4110 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4111 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4112 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4113 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4114 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4115 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4116 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4117 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4118 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4119 justify-content:center;min-height:38px;border-radius:999px;\
4120 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4121 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4122 .theme-toggle .icon-sun{{display:none;}}\
4123 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4124 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4125 .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
4126 display:flex;align-items:center;justify-content:center;\
4127 min-height:calc(100vh - 56px);}}\
4128 .panel{{background:var(--surface);border:1px solid var(--line);\
4129 border-radius:var(--radius);box-shadow:var(--shadow);\
4130 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4131 .spin-ring{{width:56px;height:56px;border-radius:50%;\
4132 border:5px solid var(--line);border-top-color:var(--oxide-2);\
4133 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4134 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4135 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4136 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4137 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4138 min-height:42px;padding:0 20px;border-radius:14px;\
4139 border:1px solid var(--line-strong);text-decoration:none;\
4140 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4141 .back-link:hover{{background:var(--line);}}\
4142 </style></head>\
4143 <body>\
4144 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4145 <a class=\"brand\" href=\"/\">\
4146 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4147 <div class=\"brand-copy\">\
4148 <div class=\"brand-title\">OxideSLOC</div>\
4149 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4150 </div>\
4151 </a>\
4152 <div class=\"nav-right\">\
4153 <a class=\"nav-pill\" href=\"/\">Home</a>\
4154 <div class=\"nav-dropdown\">\
4155 <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>\
4156 <div class=\"nav-dropdown-menu\">\
4157 <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>\
4158 </div>\
4159 </div>\
4160 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
4161 <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>\
4162 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
4163 <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>\
4164 </button>\
4165 </div>\
4166 </div></div>\
4167 <div class=\"page\"><div class=\"panel\">\
4168 <div class=\"spin-ring\"></div>\
4169 <h1>Generating PDF\u{2026}</h1>\
4170 <p>The PDF is being rendered from the HTML report.<br>\
4171 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
4172 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
4173 </div></div>\
4174 <script nonce=\"{csp_nonce}\">\
4175 (function(){{\
4176 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
4177 if(s===\"dark\")b.classList.add(\"dark-theme\");\
4178 var t=document.getElementById(\"theme-toggle\");\
4179 if(t)t.addEventListener(\"click\",function(){{\
4180 var d=b.classList.toggle(\"dark-theme\");\
4181 localStorage.setItem(k,d?\"dark\":\"light\");\
4182 }});\
4183 }})();\
4184 </script>\
4185 </body></html>"
4186 );
4187 return Html(html).into_response();
4188 }
4189 serve_pdf_artifact(
4190 &path,
4191 &artifact_set.report_title,
4192 &run_id,
4193 wants_download,
4194 &csp_nonce,
4195 )
4196 }
4197 "json" => {
4198 let Some(path) = artifact_set.json_path else {
4199 let msg = "JSON result was not generated for this run, or was not recorded in \
4200 the scan registry. Re-run the analysis with JSON output enabled."
4201 .to_string();
4202 let html = ErrorTemplate {
4203 message: msg,
4204 last_report_url: Some("/view-reports".to_string()),
4205 last_report_label: Some("View Reports".to_string()),
4206 csp_nonce: csp_nonce.clone(),
4207 }
4208 .render()
4209 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4210 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4211 };
4212 serve_json_artifact(&path, wants_download, &csp_nonce)
4213 }
4214 "scan-config" => {
4215 let path = artifact_set
4216 .scan_config_path
4217 .as_deref()
4218 .map(std::path::Path::to_path_buf)
4219 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4220 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4221 fs::read(&path).map_or_else(
4222 |_| StatusCode::NOT_FOUND.into_response(),
4223 |bytes| {
4224 (
4225 [
4226 (
4227 header::CONTENT_TYPE,
4228 "application/json; charset=utf-8".to_string(),
4229 ),
4230 (
4231 header::CONTENT_DISPOSITION,
4232 "attachment; filename=\"scan-config.json\"".to_string(),
4233 ),
4234 ],
4235 bytes,
4236 )
4237 .into_response()
4238 },
4239 )
4240 }
4241 _ if artifact.starts_with("sub_") => {
4242 if artifact.len() > 128
4243 || !artifact
4244 .chars()
4245 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4246 {
4247 return StatusCode::BAD_REQUEST.into_response();
4248 }
4249 let filename = format!("{artifact}.html");
4250 let path = artifact_set.output_dir.join(&filename);
4251 if !path.exists() {
4252 let html = ErrorTemplate {
4253 message: format!(
4254 "Sub-report '{artifact}' was not found in the run directory.\n\
4255 Re-run the analysis with 'Detect and separate git submodules' \
4256 and HTML output enabled."
4257 ),
4258 last_report_url: Some("/view-reports".to_string()),
4259 last_report_label: Some("View Reports".to_string()),
4260 csp_nonce: csp_nonce.clone(),
4261 }
4262 .render()
4263 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4264 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4265 }
4266 serve_html_artifact(&path, wants_download, &csp_nonce)
4267 }
4268 _ => StatusCode::NOT_FOUND.into_response(),
4269 }
4270}
4271
4272struct SubmoduleLinkRow {
4275 name: String,
4276 url: String,
4277}
4278
4279struct HistoryEntryRow {
4280 run_id: String,
4281 run_id_short: String,
4282 timestamp: String,
4283 timestamp_utc_ms: i64,
4284 project_label: String,
4285 project_path: String,
4286 files_analyzed: u64,
4287 files_skipped: u64,
4288 code_lines: u64,
4289 comment_lines: u64,
4290 blank_lines: u64,
4291 git_branch: String,
4292 git_commit: String,
4293 has_html: bool,
4294 has_json: bool,
4295 has_pdf: bool,
4296 submodule_links: Vec<SubmoduleLinkRow>,
4297 submodule_names_csv: String,
4299}
4300
4301fn nth_weekday_of_month(
4303 year: i32,
4304 month: u32,
4305 weekday: chrono::Weekday,
4306 n: u32,
4307) -> chrono::NaiveDate {
4308 use chrono::Datelike;
4309 let mut count = 0u32;
4310 let mut day = 1u32;
4311 loop {
4312 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4313 if d.weekday() == weekday {
4314 count += 1;
4315 if count == n {
4316 return d;
4317 }
4318 }
4319 day += 1;
4320 }
4321}
4322
4323fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4327 use chrono::{Datelike, TimeZone};
4328 let year = dt.year();
4329 let dst_start = chrono::Utc.from_utc_datetime(
4330 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4331 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4332 );
4333 let dst_end = chrono::Utc.from_utc_datetime(
4334 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4335 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4336 );
4337 dt >= dst_start && dt < dst_end
4338}
4339
4340fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4341 if is_pacific_dst(dt) {
4342 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4343 .format("%Y-%m-%d %H:%M PDT")
4344 .to_string()
4345 } else {
4346 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4347 .format("%Y-%m-%d %H:%M PST")
4348 .to_string()
4349 }
4350}
4351
4352fn fmt_git_date(iso: &str) -> Option<String> {
4353 chrono::DateTime::parse_from_rfc3339(iso)
4354 .ok()
4355 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4356}
4357
4358fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4359 reg.entries
4360 .iter()
4361 .map(|e| {
4362 let submodule_links = {
4363 let mut links: Vec<SubmoduleLinkRow> = vec![];
4364 let sub_dir = e
4365 .html_path
4366 .as_ref()
4367 .and_then(|p| p.parent())
4368 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4369 if let Some(dir) = sub_dir {
4370 if let Ok(rd) = std::fs::read_dir(dir) {
4371 for entry_res in rd.flatten() {
4372 let fname = entry_res.file_name();
4373 let fname_str = fname.to_string_lossy();
4374 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4375 let stem = &fname_str[..fname_str.len() - 5];
4376 let display = stem[4..].replace('-', " ");
4377 links.push(SubmoduleLinkRow {
4378 name: display,
4379 url: format!("/runs/{stem}/{}", e.run_id),
4380 });
4381 }
4382 }
4383 }
4384 }
4385 links.sort_by(|a, b| a.name.cmp(&b.name));
4386 links
4387 };
4388 let submodule_names_csv = submodule_links
4389 .iter()
4390 .map(|l| l.name.as_str())
4391 .collect::<Vec<_>>()
4392 .join(",");
4393 HistoryEntryRow {
4394 run_id: e.run_id.clone(),
4395 run_id_short: e
4396 .run_id
4397 .split('-')
4398 .next_back()
4399 .unwrap_or(&e.run_id)
4400 .chars()
4401 .take(7)
4402 .collect(),
4403 timestamp: fmt_la_time(e.timestamp_utc),
4404 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4405 project_label: e.project_label.clone(),
4406 project_path: e
4407 .input_roots
4408 .first()
4409 .map(|s| sanitize_path_str(s))
4410 .unwrap_or_default(),
4411 files_analyzed: e.summary.files_analyzed,
4412 files_skipped: e.summary.files_skipped,
4413 code_lines: e.summary.code_lines,
4414 comment_lines: e.summary.comment_lines,
4415 blank_lines: e.summary.blank_lines,
4416 git_branch: e.git_branch.clone().unwrap_or_default(),
4417 git_commit: e.git_commit.clone().unwrap_or_default(),
4418 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4419 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4420 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4421 submodule_links,
4422 submodule_names_csv,
4423 }
4424 })
4425 .collect()
4426}
4427
4428#[derive(Deserialize, Default)]
4429struct HistoryQuery {
4430 linked: Option<String>,
4431 error: Option<String>,
4432}
4433
4434async fn history_handler(
4435 State(state): State<AppState>,
4436 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4437 Query(query): Query<HistoryQuery>,
4438) -> impl IntoResponse {
4439 auto_scan_watched_dirs(&state).await;
4441 let watched_dirs: Vec<String> = {
4442 let wd = state.watched_dirs.lock().await;
4443 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4444 };
4445 let mut entries = {
4446 let reg = state.registry.lock().await;
4447 make_history_rows(®)
4448 };
4449 entries.retain(|e| e.has_html);
4450 let total_scans = entries.len();
4451 let linked_count = query
4452 .linked
4453 .as_deref()
4454 .and_then(|s| s.parse::<usize>().ok())
4455 .unwrap_or(0);
4456 let browse_error = query.error.filter(|s| !s.is_empty());
4457 let template = HistoryTemplate {
4458 version: env!("CARGO_PKG_VERSION"),
4459 entries,
4460 total_scans,
4461 linked_count,
4462 browse_error,
4463 watched_dirs,
4464 csp_nonce,
4465 };
4466 Html(
4467 template
4468 .render()
4469 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4470 )
4471 .into_response()
4472}
4473
4474async fn compare_select_handler(
4475 State(state): State<AppState>,
4476 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4477) -> impl IntoResponse {
4478 auto_scan_watched_dirs(&state).await;
4479 let watched_dirs: Vec<String> = {
4480 let wd = state.watched_dirs.lock().await;
4481 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4482 };
4483 let mut entries = {
4484 let reg = state.registry.lock().await;
4485 make_history_rows(®)
4486 };
4487 entries.retain(|e| e.has_json);
4488 let total_scans = entries.len();
4489 let template = CompareSelectTemplate {
4490 version: env!("CARGO_PKG_VERSION"),
4491 entries,
4492 total_scans,
4493 watched_dirs,
4494 csp_nonce,
4495 };
4496 Html(
4497 template
4498 .render()
4499 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4500 )
4501 .into_response()
4502}
4503
4504#[derive(Deserialize, Default)]
4507struct CompareQuery {
4508 a: Option<String>,
4509 b: Option<String>,
4510 sub: Option<String>,
4512 scope: Option<String>,
4514}
4515
4516struct CompareFileDeltaRow {
4517 relative_path: String,
4518 language: String,
4519 status: String,
4520 baseline_code: i64,
4521 current_code: i64,
4522 code_delta_str: String,
4523 code_delta_class: String,
4524 comment_delta_str: String,
4525 comment_delta_class: String,
4526 total_delta_str: String,
4527 total_delta_class: String,
4528}
4529
4530fn recompute_summary_from_records(run: &mut AnalysisRun) {
4533 let files_analyzed = run
4534 .per_file_records
4535 .iter()
4536 .filter(|r| r.language.is_some())
4537 .count() as u64;
4538 let code_lines: u64 = run
4539 .per_file_records
4540 .iter()
4541 .map(|r| r.effective_counts.code_lines)
4542 .sum();
4543 let comment_lines: u64 = run
4544 .per_file_records
4545 .iter()
4546 .map(|r| r.effective_counts.comment_lines)
4547 .sum();
4548 let blank_lines: u64 = run
4549 .per_file_records
4550 .iter()
4551 .map(|r| r.effective_counts.blank_lines)
4552 .sum();
4553 run.summary_totals.files_analyzed = files_analyzed;
4554 run.summary_totals.files_considered = files_analyzed;
4555 run.summary_totals.code_lines = code_lines;
4556 run.summary_totals.comment_lines = comment_lines;
4557 run.summary_totals.blank_lines = blank_lines;
4558 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4559}
4560
4561fn fmt_delta(n: i64) -> String {
4562 if n > 0 {
4563 format!("+{n}")
4564 } else {
4565 format!("{n}")
4566 }
4567}
4568
4569fn delta_class(n: i64) -> &'static str {
4570 use std::cmp::Ordering;
4571 match n.cmp(&0) {
4572 Ordering::Greater => "pos",
4573 Ordering::Less => "neg",
4574 Ordering::Equal => "zero",
4575 }
4576}
4577
4578#[allow(clippy::cast_precision_loss)]
4580fn fmt_pct(delta: i64, baseline: u64) -> String {
4581 if baseline == 0 {
4582 return "—".to_string();
4583 }
4584 #[allow(clippy::cast_precision_loss)]
4585 let pct = (delta as f64 / baseline as f64) * 100.0;
4586 if pct > 0.049 {
4587 format!("+{pct:.1}%")
4588 } else if pct < -0.049 {
4589 format!("{pct:.1}%")
4590 } else {
4591 "±0%".to_string()
4592 }
4593}
4594
4595fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4597 prev.map_or_else(
4598 || ("—".to_string(), "na"),
4599 |p| {
4600 #[allow(clippy::cast_possible_wrap)]
4601 let d = curr as i64 - p as i64;
4602 (fmt_delta(d), delta_class(d))
4603 },
4604 )
4605}
4606
4607#[allow(clippy::too_many_lines)]
4608async fn compare_handler(
4609 State(state): State<AppState>,
4611 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4612 Query(query): Query<CompareQuery>,
4613) -> impl IntoResponse {
4614 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4617 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4618 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4619 };
4620
4621 let (maybe_a, maybe_b) = {
4622 let reg = state.registry.lock().await;
4623 (
4624 reg.find_by_run_id(&run_id_a).cloned(),
4625 reg.find_by_run_id(&run_id_b).cloned(),
4626 )
4627 };
4628
4629 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4630 let html = ErrorTemplate {
4631 message: "One or both run IDs were not found in scan history. \
4632 The runs may have been deleted or the registry may have been reset."
4633 .to_string(),
4634 last_report_url: Some("/compare-scans".to_string()),
4635 last_report_label: Some("Compare Scans".to_string()),
4636 csp_nonce: csp_nonce.clone(),
4637 }
4638 .render()
4639 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4640 return Html(html).into_response();
4641 };
4642
4643 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4645 (entry_a, entry_b)
4646 } else {
4647 (entry_b, entry_a)
4648 };
4649
4650 if baseline_entry.run_id != run_id_a {
4654 let canonical = format!(
4655 "/compare?a={}&b={}",
4656 baseline_entry.run_id, current_entry.run_id
4657 );
4658 return axum::response::Redirect::to(&canonical).into_response();
4659 }
4660
4661 let (Some(base_json), Some(curr_json)) = (
4662 baseline_entry.json_path.as_ref(),
4663 current_entry.json_path.as_ref(),
4664 ) else {
4665 let html = ErrorTemplate {
4666 message: "Full comparison requires JSON scan data, which was not saved for one or \
4667 both of these runs. JSON is now always saved for new scans — re-run the \
4668 affected projects to enable comparisons."
4669 .to_string(),
4670 last_report_url: Some("/compare-scans".to_string()),
4671 last_report_label: Some("Compare Scans".to_string()),
4672 csp_nonce: csp_nonce.clone(),
4673 }
4674 .render()
4675 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4676 return Html(html).into_response();
4677 };
4678
4679 let compare_url = format!(
4680 "/compare?a={}&b={}",
4681 baseline_entry.run_id, current_entry.run_id
4682 );
4683
4684 let baseline_run = match read_json(base_json) {
4685 Ok(r) => r,
4686 Err(e) => {
4687 if state.server_mode {
4688 let html = ErrorTemplate {
4689 message: "Could not load baseline scan data. The scan output folder may \
4690 have been moved, renamed, or deleted. Re-running the analysis \
4691 will create fresh comparison data."
4692 .to_string(),
4693 last_report_url: Some("/compare-scans".to_string()),
4694 last_report_label: Some("Compare Scans".to_string()),
4695 csp_nonce: csp_nonce.clone(),
4696 }
4697 .render()
4698 .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
4699 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4700 }
4701 let msg = format!(
4702 "Could not load baseline scan data.\n\nExpected path: {}\n\nError: {e}",
4703 base_json.display()
4704 );
4705 let folder_hint = base_json
4706 .parent()
4707 .map(|p| p.display().to_string())
4708 .unwrap_or_default();
4709 return missing_scan_relocate_response(
4710 &msg,
4711 &baseline_entry.run_id,
4712 &folder_hint,
4713 &compare_url,
4714 false,
4715 &csp_nonce,
4716 );
4717 }
4718 };
4719 let current_run = match read_json(curr_json) {
4720 Ok(r) => r,
4721 Err(e) => {
4722 if state.server_mode {
4723 let html = ErrorTemplate {
4724 message: "Could not load current scan data. The scan output folder may \
4725 have been moved, renamed, or deleted. Re-running the analysis \
4726 will create fresh comparison data."
4727 .to_string(),
4728 last_report_url: Some("/compare-scans".to_string()),
4729 last_report_label: Some("Compare Scans".to_string()),
4730 csp_nonce: csp_nonce.clone(),
4731 }
4732 .render()
4733 .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
4734 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4735 }
4736 let msg = format!(
4737 "Could not load current scan data.\n\nExpected path: {}\n\nError: {e}",
4738 curr_json.display()
4739 );
4740 let folder_hint = curr_json
4741 .parent()
4742 .map(|p| p.display().to_string())
4743 .unwrap_or_default();
4744 return missing_scan_relocate_response(
4745 &msg,
4746 ¤t_entry.run_id,
4747 &folder_hint,
4748 &compare_url,
4749 false,
4750 &csp_nonce,
4751 );
4752 }
4753 };
4754
4755 let active_submodule = query.sub.clone();
4756 let super_scope_active = query.scope.as_deref() == Some("super");
4757
4758 let submodule_options = {
4761 let mut names = std::collections::BTreeSet::new();
4762 for s in &baseline_run.submodule_summaries {
4763 names.insert(s.name.clone());
4764 }
4765 for s in ¤t_run.submodule_summaries {
4766 names.insert(s.name.clone());
4767 }
4768 names.into_iter().collect::<Vec<_>>()
4769 };
4770 let has_any_submodule_data = !submodule_options.is_empty();
4771
4772 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4774 let mut b = baseline_run;
4775 let mut c = current_run;
4776 b.per_file_records
4777 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4778 c.per_file_records
4779 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4780 recompute_summary_from_records(&mut b);
4781 recompute_summary_from_records(&mut c);
4782 (b, c)
4783 } else if super_scope_active {
4784 let mut b = baseline_run;
4785 let mut c = current_run;
4786 b.per_file_records.retain(|f| f.submodule.is_none());
4787 c.per_file_records.retain(|f| f.submodule.is_none());
4788 recompute_summary_from_records(&mut b);
4789 recompute_summary_from_records(&mut c);
4790 (b, c)
4791 } else {
4792 (baseline_run, current_run)
4793 };
4794
4795 let comparison = compute_delta(&effective_baseline, &effective_current);
4796
4797 let file_rows: Vec<CompareFileDeltaRow> = comparison
4798 .file_deltas
4799 .iter()
4800 .map(|d| CompareFileDeltaRow {
4801 relative_path: d.relative_path.clone(),
4802 language: d.language.clone().unwrap_or_else(|| "—".into()),
4803 status: match d.status {
4804 FileChangeStatus::Added => "added".into(),
4805 FileChangeStatus::Removed => "removed".into(),
4806 FileChangeStatus::Modified => "modified".into(),
4807 FileChangeStatus::Unchanged => "unchanged".into(),
4808 },
4809 baseline_code: d.baseline_code,
4810 current_code: d.current_code,
4811 code_delta_str: fmt_delta(d.code_delta),
4812 code_delta_class: delta_class(d.code_delta).into(),
4813 comment_delta_str: fmt_delta(d.comment_delta),
4814 comment_delta_class: delta_class(d.comment_delta).into(),
4815 total_delta_str: fmt_delta(d.total_delta),
4816 total_delta_class: delta_class(d.total_delta).into(),
4817 })
4818 .collect();
4819
4820 let project_path = baseline_entry
4821 .input_roots
4822 .first()
4823 .map(|s| sanitize_path_str(s))
4824 .unwrap_or_default();
4825 let lines_added = sum_added_code_lines(&comparison);
4826 let lines_removed = sum_removed_code_lines(&comparison);
4827 let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
4830 #[allow(clippy::cast_precision_loss)]
4832 let churn_pct = if comparison.summary.baseline_code > 0 {
4833 (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
4834 } else {
4835 0.0
4836 };
4837 #[allow(clippy::cast_precision_loss)]
4838 let scope_flag = new_scope
4839 || (comparison.summary.baseline_code > 0
4840 && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
4841 let s = &comparison.summary;
4842 let template = CompareTemplate {
4843 version: env!("CARGO_PKG_VERSION"),
4844 project_label: baseline_entry.project_label.clone(),
4845 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4846 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4847 baseline_run_id: baseline_entry.run_id.clone(),
4848 current_run_id: current_entry.run_id.clone(),
4849 baseline_run_id_short: baseline_entry
4850 .run_id
4851 .split('-')
4852 .next_back()
4853 .unwrap_or(&baseline_entry.run_id)
4854 .chars()
4855 .take(7)
4856 .collect(),
4857 current_run_id_short: current_entry
4858 .run_id
4859 .split('-')
4860 .next_back()
4861 .unwrap_or(¤t_entry.run_id)
4862 .chars()
4863 .take(7)
4864 .collect(),
4865 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
4866 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
4867 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
4868 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
4869 project_path: project_path.clone(),
4870 baseline_code: s.baseline_code,
4871 current_code: s.current_code,
4872 code_lines_delta_str: fmt_delta(s.code_lines_delta),
4873 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
4874 baseline_files: s.baseline_files,
4875 current_files: s.current_files,
4876 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
4877 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
4878 baseline_comments: s.baseline_comments,
4879 current_comments: s.current_comments,
4880 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
4881 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
4882 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
4883 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
4884 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
4885 code_lines_added: lines_added,
4886 code_lines_removed: lines_removed,
4887 new_scope,
4888 churn_rate_str: if new_scope {
4889 "New".to_string()
4890 } else if s.baseline_code > 0 {
4891 format!("{churn_pct:.1}%")
4892 } else {
4893 "—".to_string()
4894 },
4895 churn_rate_class: if new_scope || churn_pct > 20.0 {
4896 "high".into()
4897 } else if churn_pct > 5.0 {
4898 "med".into()
4899 } else {
4900 "low".into()
4901 },
4902 scope_flag,
4903 files_added: comparison.files_added,
4904 files_removed: comparison.files_removed,
4905 files_modified: comparison.files_modified,
4906 files_unchanged: comparison.files_unchanged,
4907 file_rows,
4908 baseline_git_author: baseline_entry.git_author.clone(),
4909 current_git_author: current_entry.git_author.clone(),
4910 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
4911 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
4912 baseline_git_tags: baseline_entry.git_tags.clone(),
4913 current_git_tags: current_entry.git_tags.clone(),
4914 baseline_git_commit_date: baseline_entry
4915 .git_commit_date
4916 .as_deref()
4917 .and_then(fmt_git_date),
4918 current_git_commit_date: current_entry
4919 .git_commit_date
4920 .as_deref()
4921 .and_then(fmt_git_date),
4922 project_name: project_path
4923 .rsplit(['/', '\\'])
4924 .find(|s| !s.is_empty())
4925 .unwrap_or(&project_path)
4926 .to_string(),
4927 submodule_options,
4928 has_any_submodule_data,
4929 active_submodule,
4930 super_scope_active,
4931 csp_nonce,
4932 };
4933
4934 Html(
4935 template
4936 .render()
4937 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4938 )
4939 .into_response()
4940}
4941
4942fn format_number(n: u64) -> String {
4950 let s = n.to_string();
4951 let mut out = String::with_capacity(s.len() + s.len() / 3);
4952 let len = s.len();
4953 for (i, c) in s.chars().enumerate() {
4954 if i > 0 && (len - i).is_multiple_of(3) {
4955 out.push(',');
4956 }
4957 out.push(c);
4958 }
4959 out
4960}
4961
4962const fn badge_char_width(c: char) -> f64 {
4963 match c {
4964 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
4965 'm' | 'w' => 9.0,
4966 ' ' => 4.0,
4967 _ => 6.5,
4968 }
4969}
4970
4971#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4972fn badge_text_px(text: &str) -> u32 {
4973 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
4974}
4975
4976fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
4977 let lw = badge_text_px(label) + 20;
4978 let rw = badge_text_px(value) + 20;
4979 let total = lw + rw;
4980 let lx = lw / 2;
4981 let rx = lw + rw / 2;
4982 let le = escape_html(label);
4983 let ve = escape_html(value);
4984 let ce = escape_html(color);
4985 format!(
4986 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
4987 <rect width="{total}" height="20" fill="#555"/>
4988 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
4989 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
4990 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
4991 <text x="{lx}" y="13">{le}</text>
4992 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
4993 <text x="{rx}" y="13">{ve}</text>
4994 </g>
4995</svg>"##
4996 )
4997}
4998
4999#[derive(Deserialize)]
5000struct BadgeQuery {
5001 label: Option<String>,
5002 color: Option<String>,
5003}
5004
5005async fn badge_handler(
5006 State(state): State<AppState>,
5007 AxumPath(metric): AxumPath<String>,
5008 Query(query): Query<BadgeQuery>,
5009) -> Response {
5010 let entry = {
5011 let reg = state.registry.lock().await;
5012 reg.entries.first().cloned()
5013 };
5014
5015 let Some(entry) = entry else {
5016 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
5017 return (
5018 [
5019 (header::CONTENT_TYPE, "image/svg+xml"),
5020 (header::CACHE_CONTROL, "no-cache, max-age=0"),
5021 ],
5022 svg,
5023 )
5024 .into_response();
5025 };
5026
5027 let (default_label, value, default_color) = match metric.as_str() {
5028 "code-lines" => (
5029 "code lines",
5030 format_number(entry.summary.code_lines),
5031 "#4a78ee",
5032 ),
5033 "files" => (
5034 "files analyzed",
5035 format_number(entry.summary.files_analyzed),
5036 "#4a9862",
5037 ),
5038 "comment-lines" => (
5039 "comment lines",
5040 format_number(entry.summary.comment_lines),
5041 "#b35428",
5042 ),
5043 "blank-lines" => (
5044 "blank lines",
5045 format_number(entry.summary.blank_lines),
5046 "#7a5db0",
5047 ),
5048 _ => return StatusCode::NOT_FOUND.into_response(),
5049 };
5050
5051 let label = query.label.as_deref().unwrap_or(default_label);
5052 let color = query.color.as_deref().unwrap_or(default_color);
5053 let svg = render_badge_svg(label, &value, color);
5054
5055 (
5056 [
5057 (header::CONTENT_TYPE, "image/svg+xml"),
5058 (header::CACHE_CONTROL, "no-cache, max-age=0"),
5059 ],
5060 svg,
5061 )
5062 .into_response()
5063}
5064
5065#[derive(Serialize)]
5073struct ApiMetricsResponse {
5074 run_id: String,
5075 timestamp: String,
5076 project: String,
5077 summary: ApiSummaryPayload,
5078 languages: Vec<ApiLanguageRow>,
5079}
5080
5081#[derive(Serialize)]
5082struct ApiSummaryPayload {
5083 files_analyzed: u64,
5084 files_skipped: u64,
5085 code_lines: u64,
5086 comment_lines: u64,
5087 blank_lines: u64,
5088 total_physical_lines: u64,
5089 functions: u64,
5090 classes: u64,
5091 variables: u64,
5092 imports: u64,
5093}
5094
5095#[derive(Serialize)]
5096struct ApiLanguageRow {
5097 name: String,
5098 files: u64,
5099 code_lines: u64,
5100 comment_lines: u64,
5101 blank_lines: u64,
5102 functions: u64,
5103 classes: u64,
5104 variables: u64,
5105 imports: u64,
5106}
5107
5108async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5109 let entry = {
5110 let reg = state.registry.lock().await;
5111 reg.entries.first().cloned()
5112 };
5113 entry.map_or_else(
5114 || {
5115 (
5116 StatusCode::NOT_FOUND,
5117 Json(serde_json::json!({"error": "no scans recorded yet"})),
5118 )
5119 .into_response()
5120 },
5121 |e| build_metrics_response(&e),
5122 )
5123}
5124
5125async fn api_metrics_run_handler(
5126 State(state): State<AppState>,
5127 AxumPath(run_id): AxumPath<String>,
5128) -> Response {
5129 let entry = {
5130 let reg = state.registry.lock().await;
5131 reg.find_by_run_id(&run_id).cloned()
5132 };
5133 entry.map_or_else(
5134 || {
5135 (
5136 StatusCode::NOT_FOUND,
5137 Json(serde_json::json!({"error": "run not found"})),
5138 )
5139 .into_response()
5140 },
5141 |e| build_metrics_response(&e),
5142 )
5143}
5144
5145fn build_metrics_response(entry: &RegistryEntry) -> Response {
5146 let languages: Vec<ApiLanguageRow> = entry
5147 .json_path
5148 .as_ref()
5149 .and_then(|p| read_json(p).ok())
5150 .map(|run| {
5151 run.totals_by_language
5152 .iter()
5153 .map(|l| ApiLanguageRow {
5154 name: l.language.display_name().to_string(),
5155 files: l.files,
5156 code_lines: l.code_lines,
5157 comment_lines: l.comment_lines,
5158 blank_lines: l.blank_lines,
5159 functions: l.functions,
5160 classes: l.classes,
5161 variables: l.variables,
5162 imports: l.imports,
5163 })
5164 .collect()
5165 })
5166 .unwrap_or_default();
5167
5168 let s = &entry.summary;
5169 Json(ApiMetricsResponse {
5170 run_id: entry.run_id.clone(),
5171 timestamp: entry.timestamp_utc.to_rfc3339(),
5172 project: entry.project_label.clone(),
5173 summary: ApiSummaryPayload {
5174 files_analyzed: s.files_analyzed,
5175 files_skipped: s.files_skipped,
5176 code_lines: s.code_lines,
5177 comment_lines: s.comment_lines,
5178 blank_lines: s.blank_lines,
5179 total_physical_lines: s.total_physical_lines,
5180 functions: s.functions,
5181 classes: s.classes,
5182 variables: s.variables,
5183 imports: s.imports,
5184 },
5185 languages,
5186 })
5187 .into_response()
5188}
5189
5190#[derive(Deserialize)]
5197struct ProjectHistoryQuery {
5198 path: Option<String>,
5199}
5200
5201#[derive(Serialize)]
5202struct ProjectHistoryResponse {
5203 scan_count: usize,
5204 last_scan_id: Option<String>,
5205 last_scan_timestamp: Option<String>,
5206 last_scan_code_lines: Option<u64>,
5207 last_git_branch: Option<String>,
5208 last_git_commit: Option<String>,
5209}
5210
5211async fn project_history_handler(
5212 State(state): State<AppState>,
5213 Query(query): Query<ProjectHistoryQuery>,
5214) -> Response {
5215 let path = query.path.unwrap_or_default();
5216 let resolved = resolve_input_path(&path);
5217 let root_str = resolved.to_string_lossy().replace('\\', "/");
5218
5219 let entries: Vec<_> = {
5220 let reg = state.registry.lock().await;
5221 reg.entries
5222 .iter()
5223 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5224 .cloned()
5225 .collect()
5226 };
5227 let scan_count = entries.len();
5228 let last = entries.first();
5229 let last_scan_id = last.map(|e| e.run_id.clone());
5230 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5231 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5232 let last_git_branch = last.and_then(|e| e.git_branch.clone());
5233 let last_git_commit = last.and_then(|e| e.git_commit.clone());
5234
5235 Json(ProjectHistoryResponse {
5236 scan_count,
5237 last_scan_id,
5238 last_scan_timestamp,
5239 last_scan_code_lines,
5240 last_git_branch,
5241 last_git_commit,
5242 })
5243 .into_response()
5244}
5245
5246#[derive(Deserialize)]
5253struct MetricsHistoryQuery {
5254 root: Option<String>,
5255 limit: Option<usize>,
5256 submodule: Option<String>,
5259}
5260
5261#[derive(Serialize)]
5262struct MetricsSubmoduleLink {
5263 name: String,
5264 url: String,
5265}
5266
5267#[derive(Serialize)]
5268struct MetricsHistoryEntry {
5269 run_id: String,
5270 run_id_short: String,
5271 timestamp: String,
5272 commit: Option<String>,
5273 branch: Option<String>,
5274 tags: Vec<String>,
5275 nearest_tag: Option<String>,
5276 code_lines: u64,
5277 comment_lines: u64,
5278 blank_lines: u64,
5279 physical_lines: u64,
5280 files_analyzed: u64,
5281 files_skipped: u64,
5282 test_count: u64,
5283 project_label: String,
5284 html_url: Option<String>,
5285 has_pdf: bool,
5286 submodule_links: Vec<MetricsSubmoduleLink>,
5287}
5288
5289#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
5291 State(state): State<AppState>,
5293 Query(query): Query<MetricsHistoryQuery>,
5294) -> Response {
5295 let limit = query.limit.unwrap_or(50).min(500);
5296 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5297
5298 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5299 let reg = state.registry.lock().await;
5300 reg.entries
5301 .iter()
5302 .filter(|e| {
5303 query.root.as_ref().is_none_or(|root| {
5304 let resolved = resolve_input_path(root);
5305 let root_str = resolved.to_string_lossy().replace('\\', "/");
5306 e.input_roots.iter().any(|r| r == &root_str)
5307 })
5308 })
5309 .take(limit)
5310 .cloned()
5311 .collect()
5312 };
5313
5314 let entries: Vec<MetricsHistoryEntry> = candidate_entries
5315 .into_iter()
5316 .filter_map(|e| {
5317 let tags = e
5318 .git_tags
5319 .as_deref()
5320 .map(|s| {
5321 s.split(',')
5322 .map(|t| t.trim().to_string())
5323 .filter(|t| !t.is_empty())
5324 .collect()
5325 })
5326 .unwrap_or_default();
5327 let html_url = e
5328 .html_path
5329 .as_ref()
5330 .filter(|p| p.exists())
5331 .map(|_| format!("/runs/html/{}", e.run_id));
5332 let nearest_tag = e.git_nearest_tag.clone();
5333 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5334 let run_id_short: String = e
5335 .run_id
5336 .split('-')
5337 .next_back()
5338 .unwrap_or(&e.run_id)
5339 .chars()
5340 .take(7)
5341 .collect();
5342 let submodule_links: Vec<MetricsSubmoduleLink> = {
5343 let mut links: Vec<MetricsSubmoduleLink> = vec![];
5344 let sub_dir = e
5345 .html_path
5346 .as_ref()
5347 .and_then(|p| p.parent())
5348 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5349 if let Some(dir) = sub_dir {
5350 if let Ok(rd) = std::fs::read_dir(dir) {
5351 for entry_res in rd.flatten() {
5352 let fname = entry_res.file_name();
5353 let fname_str = fname.to_string_lossy();
5354 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5355 let stem = &fname_str[..fname_str.len() - 5];
5356 let display = stem[4..].replace('-', " ");
5357 links.push(MetricsSubmoduleLink {
5358 name: display,
5359 url: format!("/runs/{stem}/{}", e.run_id),
5360 });
5361 }
5362 }
5363 }
5364 }
5365 links.sort_by(|a, b| a.name.cmp(&b.name));
5366 links
5367 };
5368 let base = MetricsHistoryEntry {
5369 run_id: e.run_id.clone(),
5370 run_id_short,
5371 timestamp: e.timestamp_utc.to_rfc3339(),
5372 commit: e.git_commit.clone(),
5373 branch: e.git_branch.clone(),
5374 tags,
5375 nearest_tag,
5376 code_lines: e.summary.code_lines,
5377 comment_lines: e.summary.comment_lines,
5378 blank_lines: e.summary.blank_lines,
5379 physical_lines: e.summary.total_physical_lines,
5380 files_analyzed: e.summary.files_analyzed,
5381 files_skipped: e.summary.files_skipped,
5382 test_count: e.summary.test_count,
5383 project_label: e.project_label.clone(),
5384 html_url,
5385 has_pdf,
5386 submodule_links,
5387 };
5388 if let Some(ref filter) = submodule_filter {
5389 let json_path = e.json_path.as_ref()?;
5391 let json_str = std::fs::read_to_string(json_path).ok()?;
5392 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5393 let sub = run.submodule_summaries.iter().find(|s| {
5394 s.name.to_lowercase() == *filter || s.relative_path.to_lowercase() == *filter
5395 })?;
5396 let safe = sanitize_project_label(&sub.name);
5398 let artifact_key = format!("sub_{safe}");
5399 let sub_html_url = if let Some(run_dir) = std::path::Path::new(json_path).parent() {
5400 let sub_path = run_dir.join(format!("{artifact_key}.html"));
5401 if sub_path.exists() {
5402 Some(format!("/runs/{artifact_key}/{}", e.run_id))
5403 } else {
5404 base.html_url.clone()
5405 }
5406 } else {
5407 base.html_url.clone()
5408 };
5409 Some(MetricsHistoryEntry {
5410 code_lines: sub.code_lines,
5411 comment_lines: sub.comment_lines,
5412 blank_lines: sub.blank_lines,
5413 physical_lines: sub.total_physical_lines,
5414 files_analyzed: sub.files_analyzed,
5415 html_url: sub_html_url,
5416 has_pdf: false,
5417 submodule_links: vec![],
5418 ..base
5419 })
5420 } else {
5421 Some(base)
5422 }
5423 })
5424 .collect();
5425
5426 Json(entries).into_response()
5427}
5428
5429#[derive(Deserialize)]
5433struct MetricsSubmodulesQuery {
5434 root: Option<String>,
5435}
5436
5437#[derive(Serialize)]
5438struct SubmoduleEntry {
5439 name: String,
5440 relative_path: String,
5441}
5442
5443async fn api_metrics_submodules_handler(
5444 State(state): State<AppState>,
5445 Query(query): Query<MetricsSubmodulesQuery>,
5446) -> Response {
5447 let json_paths: Vec<std::path::PathBuf> = {
5448 let reg = state.registry.lock().await;
5449 reg.entries
5450 .iter()
5451 .filter(|e| {
5452 query.root.as_ref().is_none_or(|root| {
5453 let resolved = resolve_input_path(root);
5454 let root_str = resolved.to_string_lossy().replace('\\', "/");
5455 e.input_roots.iter().any(|r| r == &root_str)
5456 })
5457 })
5458 .filter_map(|e| e.json_path.clone())
5459 .collect()
5460 };
5461
5462 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5463 let mut result: Vec<SubmoduleEntry> = Vec::new();
5464
5465 for path in &json_paths {
5466 let Ok(json_str) = std::fs::read_to_string(path) else {
5467 continue;
5468 };
5469 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5470 continue;
5471 };
5472 for sub in &run.submodule_summaries {
5473 if seen.insert(sub.name.clone()) {
5474 result.push(SubmoduleEntry {
5475 name: sub.name.clone(),
5476 relative_path: sub.relative_path.clone(),
5477 });
5478 }
5479 }
5480 }
5481
5482 result.sort_by(|a, b| a.name.cmp(&b.name));
5483 Json(result).into_response()
5484}
5485
5486#[derive(Deserialize)]
5495struct IngestQuery {
5496 label: Option<String>,
5497}
5498
5499async fn api_ingest_handler(
5500 State(state): State<AppState>,
5501 Query(q): Query<IngestQuery>,
5502 Json(run): Json<sloc_core::AnalysisRun>,
5503) -> Response {
5504 let label = q.label.unwrap_or_else(|| {
5505 run.input_roots
5506 .first()
5507 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5508 });
5509
5510 let label_for_task = label.clone();
5511 let result = tokio::task::spawn_blocking(move || {
5512 let html = render_html(&run)?;
5513 let run_id = run.tool.run_id.clone();
5514 let run_id_safe = run_id.len() <= 128
5515 && !run_id.is_empty()
5516 && run_id
5517 .chars()
5518 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5519 if !run_id_safe {
5520 anyhow::bail!(
5521 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5522 );
5523 }
5524 let project_label = sanitize_project_label(&label_for_task);
5525 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5526 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5527 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5528 _ => project_label,
5529 };
5530 let (artifacts, _pending_pdf) = persist_run_artifacts(
5531 &run,
5532 &html,
5533 &output_dir,
5534 true,
5535 true,
5536 false,
5537 &label_for_task,
5538 &file_stem,
5539 RunResultContext::default(),
5540 )?;
5541 Ok::<_, anyhow::Error>((run_id, artifacts, run))
5542 })
5543 .await;
5544
5545 match result {
5546 Ok(Ok((run_id, artifacts, run))) => {
5547 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5548 (
5549 StatusCode::CREATED,
5550 Json(serde_json::json!({
5551 "run_id": run_id,
5552 "view_url": format!("/view-reports?run_id={run_id}"),
5553 })),
5554 )
5555 .into_response()
5556 }
5557 Ok(Err(e)) => (
5558 StatusCode::INTERNAL_SERVER_ERROR,
5559 Json(serde_json::json!({"error": format!("{e:#}")})),
5560 )
5561 .into_response(),
5562 Err(e) => (
5563 StatusCode::INTERNAL_SERVER_ERROR,
5564 Json(serde_json::json!({"error": format!("{e}")})),
5565 )
5566 .into_response(),
5567 }
5568}
5569
5570#[allow(clippy::too_many_lines)] async fn trend_report_handler(
5578 State(state): State<AppState>,
5580 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5581) -> Response {
5582 auto_scan_watched_dirs(&state).await;
5583
5584 let watched_dirs_list: Vec<String> = {
5585 let wd = state.watched_dirs.lock().await;
5586 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5587 };
5588
5589 let roots: Vec<String> = {
5591 let reg = state.registry.lock().await;
5592 let mut seen = std::collections::BTreeSet::new();
5593 reg.entries
5594 .iter()
5595 .flat_map(|e| e.input_roots.iter().cloned())
5596 .filter(|r| seen.insert(r.clone()))
5597 .collect()
5598 };
5599
5600 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5601 let nonce = &csp_nonce;
5602 let version = env!("CARGO_PKG_VERSION");
5603
5604 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5606 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5607 .to_string()
5608 } else {
5609 watched_dirs_list
5610 .iter()
5611 .fold(String::new(), |mut s, d| {
5612 use std::fmt::Write as _;
5613 let escaped = d.replace('&', "&").replace('"', """).replace('<', "<");
5614 write!(
5615 s,
5616 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>"#
5617 ).expect("write to String is infallible");
5618 s
5619 })
5620 };
5621 let watched_dirs_html = format!(
5622 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>"#
5623 );
5624
5625 let html = format!(
5626 r##"<!doctype html>
5627<html lang="en">
5628<head>
5629 <meta charset="utf-8" />
5630 <meta name="viewport" content="width=device-width, initial-scale=1" />
5631 <title>OxideSLOC | Trend Reports</title>
5632 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5633 <style nonce="{nonce}">
5634 :root {{
5635 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5636 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5637 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5638 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5639 --info-bg:#eef3ff; --info-text:#4467d8;
5640 }}
5641 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5642 *{{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);}}
5643 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5644 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5645 .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;}}
5646 @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));}}}}
5647 .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);}}
5648 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5649 .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));}}
5650 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5651 .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;}}
5652 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5653 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5654 @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; }} }}
5655 .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;}}
5656 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5657 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5658 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5659 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5660 .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;}}
5661 .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;}}
5662 .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;}}
5663 .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;}}
5664 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5665 .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);}}
5666 .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;}}
5667 .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;}}
5668 .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;}}
5669 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5670 .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;}}
5671 .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);}}
5672 .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;}}
5673 .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;}}
5674 .tz-select:focus{{border-color:var(--oxide);}}
5675 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5676 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5677 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5678 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5679 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5680 .trend-title-block{{flex:1;min-width:0;}}
5681 .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;}}
5682 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5683 .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;}}
5684 .chart-select:focus{{border-color:var(--accent);}}
5685 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5686 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5687 .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;}}
5688 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5689 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5690 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5691 .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);}}
5692 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5693 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5694 .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;}}
5695 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5696 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5697 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5698 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5699 .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;}}
5700 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5701 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5702 .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);}}
5703 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5704 .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;}}
5705 .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;}}
5706 .data-table tr:last-child td{{border-bottom:none;}}
5707 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5708 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5709 .table-wrap{{width:100%;overflow-x:auto;}}
5710 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5711 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5712 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5713 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5714 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5715 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5716 .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;}}
5717 .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;}}
5718 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5719 .pagination-info{{font-size:13px;color:var(--muted);}}
5720 .pagination-btns{{display:flex;gap:6px;}}
5721 .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;}}
5722 .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;}}
5723 #scan-history-table col:nth-child(1){{width:155px;}}
5724 #scan-history-table col:nth-child(2){{width:240px;}}
5725 #scan-history-table col:nth-child(3){{width:82px;}}
5726 #scan-history-table col:nth-child(4){{width:82px;}}
5727 #scan-history-table col:nth-child(5){{width:90px;}}
5728 #scan-history-table col:nth-child(6){{width:90px;}}
5729 #scan-history-table col:nth-child(7){{width:88px;}}
5730 #scan-history-table col:nth-child(8){{width:150px;}}
5731 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5732 .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;}}
5733 .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;}}
5734 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5735 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5736 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5737 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5738 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5739 .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;}}
5740 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5741 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5742 .watched-chip-rm:hover{{color:var(--oxide);}}
5743 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5744 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5745 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5746 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5747 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5748 a.run-link:hover{{text-decoration:underline;}}
5749 .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);}}
5750 .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);}}
5751 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5752 .metric-num{{font-weight:700;color:var(--text);}}
5753 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5754 .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;}}
5755 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5756 .btn.primary:hover{{opacity:.9;}}
5757 .rpt-btn{{min-width:58px;justify-content:center;}}
5758 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5759 .report-cell{{overflow:visible!important;white-space:normal!important;}}
5760 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5761 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5762 .submod-details summary::-webkit-details-marker{{display:none;}}
5763 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5764 .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;}}
5765 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5766 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5767 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5768 .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;}}
5769 .export-btn:hover{{background:var(--line);}}
5770 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5771 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5772 .site-footer a{{color:var(--muted);}}
5773 .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;}}
5774 .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;}}
5775 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5776 </style>
5777</head>
5778<body>
5779 <div class="background-watermarks" aria-hidden="true">
5780 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5781 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5782 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5783 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5784 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5785 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5786 </div>
5787 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5788 <div class="top-nav">
5789 <div class="top-nav-inner">
5790 <a class="brand" href="/">
5791 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5792 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5793 </a>
5794 <div class="nav-right">
5795 <a class="nav-pill" href="/">Home</a>
5796 <div class="nav-dropdown">
5797 <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>
5798 <div class="nav-dropdown-menu">
5799 <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>
5800 </div>
5801 </div>
5802 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5803 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5804 <div class="nav-dropdown">
5805 <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>
5806 <div class="nav-dropdown-menu">
5807 <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>
5808 </div>
5809 </div>
5810 <div class="server-status-wrap">
5811 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5812 <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>
5813 </div>
5814 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5815 <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>
5816 </button>
5817 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5818 <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>
5819 <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>
5820 </button>
5821 </div>
5822 </div>
5823 </div>
5824
5825 <div class="page">
5826 {watched_dirs_html}
5827 <div class="summary-strip" id="trend-stats"></div>
5828 <div class="panel">
5829 <div class="trend-header">
5830 <div class="trend-title-block">
5831 <h1>Trend Reports</h1>
5832 <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>
5833 <span class="chart-hint-inline">
5834 <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>
5835 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
5836 </span>
5837 </div>
5838 <div class="chart-actions">
5839 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5840 <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>
5841 Export Excel
5842 </button>
5843 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5844 <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>
5845 Export PNG
5846 </button>
5847 </div>
5848 </div>
5849
5850 <div class="controls-centered">
5851 <label>Project Root:
5852 <select class="chart-select" id="root-sel">
5853 <option value="">All projects</option>
5854 </select>
5855 </label>
5856 <label>Y Metric:
5857 <select class="chart-select" id="y-sel">
5858 <option value="code_lines">Code Lines</option>
5859 <option value="comment_lines">Comment Lines</option>
5860 <option value="blank_lines">Blank Lines</option>
5861 <option value="physical_lines">Physical Lines</option>
5862 <option value="files_analyzed">Files Analyzed</option>
5863 </select>
5864 </label>
5865 <label>X Axis:
5866 <select class="chart-select" id="x-sel">
5867 <option value="time">By Time</option>
5868 <option value="commit">By Commit</option>
5869 <option value="release">By Release</option>
5870 <option value="tag">Tagged Commits</option>
5871 </select>
5872 </label>
5873 <label id="submodule-label" style="display:none;">Submodule:
5874 <select class="chart-select" id="sub-sel">
5875 <option value="">All (project total)</option>
5876 </select>
5877 </label>
5878 <label>Chart Size:
5879 <select class="chart-select" id="scale-sel">
5880 <option value="0.75">Compact</option>
5881 <option value="1.2" selected>Normal</option>
5882 <option value="1.38">Large</option>
5883 </select>
5884 </label>
5885 </div>
5886
5887 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
5888 <div id="data-table-wrap" style="overflow-x:auto;"></div>
5889 </div>
5890 </div>
5891
5892 <script nonce="{nonce}">
5893 (function() {{
5894 // Theme persistence
5895 var b = document.body;
5896 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
5897 var tgl = document.getElementById('theme-toggle');
5898 if (tgl) tgl.addEventListener('click', function() {{
5899 var d = b.classList.toggle('dark-theme');
5900 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
5901 }});
5902
5903 // Watermark randomizer
5904 (function() {{
5905 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5906 if (!wms.length) return;
5907 var placed = [];
5908 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;}}
5909 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];}}
5910 var half=Math.floor(wms.length/2);
5911 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;}});
5912 }})();
5913
5914 // Code particles
5915 (function() {{
5916 var container = document.getElementById('code-particles');
5917 if (!container) return;
5918 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'];
5919 for (var i = 0; i < 38; i++) {{
5920 (function(idx) {{
5921 var el = document.createElement('span');
5922 el.className = 'code-particle';
5923 el.textContent = snippets[idx % snippets.length];
5924 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
5925 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
5926 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
5927 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';
5928 container.appendChild(el);
5929 }})(i);
5930 }}
5931 }})();
5932
5933 // Watched folder picker
5934 (function() {{
5935 var btn = document.getElementById('add-watched-btn');
5936 if (!btn) return;
5937 btn.addEventListener('click', function() {{
5938 fetch('/pick-directory?kind=reports')
5939 .then(function(r) {{ return r.json(); }})
5940 .then(function(data) {{
5941 if (!data.cancelled && data.selected_path) {{
5942 var form = document.createElement('form');
5943 form.method = 'POST';
5944 form.action = '/watched-dirs/add';
5945 var ri = document.createElement('input');
5946 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
5947 var fi = document.createElement('input');
5948 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
5949 form.appendChild(ri); form.appendChild(fi);
5950 document.body.appendChild(form);
5951 form.submit();
5952 }}
5953 }})
5954 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
5955 }});
5956 }})();
5957
5958 // Settings / color-scheme modal
5959 (function() {{
5960 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'}}];
5961 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);}});}}
5962 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
5963 var btn=document.getElementById('settings-btn');if(!btn)return;
5964 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
5965 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>';
5966 document.body.appendChild(m);
5967 var g=document.getElementById('scheme-grid');
5968 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);}});
5969 var cl=document.getElementById('settings-close');
5970 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);
5971 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');}});
5972 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
5973 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
5974 }})();
5975 }})();
5976
5977 var ROOTS = {roots_json};
5978 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
5979 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
5980 var allData = [];
5981
5982 // Populate root selector
5983 var rootSel = document.getElementById('root-sel');
5984 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
5985
5986 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();}}
5987 function fmtFull(n){{return Number(n).toLocaleString();}}
5988 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
5989
5990 // Tooltip
5991 var tt = document.createElement('div');
5992 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);';
5993 document.body.appendChild(tt);
5994 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
5995 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';}}
5996 function hideTT(){{tt.style.display='none';}}
5997
5998 function statExact(compact, full){{
5999 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
6000 }}
6001 function statVal(n){{
6002 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
6003 }}
6004
6005 function updateStats(data){{
6006 var statsEl=document.getElementById('trend-stats');
6007 if(!statsEl)return;
6008 if(!data||!data.length){{statsEl.innerHTML='';return;}}
6009 var yKey=document.getElementById('y-sel').value;
6010 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6011 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6012 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
6013 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
6014 var absDelta=Math.abs(delta);
6015 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
6016 var deltaExact=statExact(deltaCompact,deltaFull);
6017 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
6018 statsEl.innerHTML=
6019 '<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>'+
6020 '<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>'+
6021 '<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>'+
6022 '<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>';
6023 }}
6024
6025 var subSel = document.getElementById('sub-sel');
6026 var subLabel = document.getElementById('submodule-label');
6027
6028 function populateSubmodules(root){{
6029 if(!subSel||!subLabel)return;
6030 while(subSel.options.length>1)subSel.remove(1);
6031 subSel.value='';
6032 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
6033 fetch(url)
6034 .then(function(r){{return r.json();}})
6035 .then(function(subs){{
6036 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
6037 subs.forEach(function(s){{
6038 var o=document.createElement('option');
6039 o.value=s.name;
6040 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
6041 subSel.appendChild(o);
6042 }});
6043 subLabel.style.display='';
6044 }})
6045 .catch(function(){{subLabel.style.display='none';}});
6046 }}
6047
6048 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
6049
6050 function loadAndRender(){{
6051 var root = rootSel.value;
6052 var sub = subSel ? subSel.value : '';
6053 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
6054 document.getElementById('data-table-wrap').innerHTML='';
6055 var url = '/api/metrics/history?limit=100'
6056 + (root ? '&root='+encodeURIComponent(root) : '')
6057 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
6058 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
6059 allData = data;
6060 render(data);
6061 updateStats(data);
6062 }}).catch(function(){{
6063 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>';
6064 }});
6065 }}
6066
6067 function render(data){{
6068 var yKey = document.getElementById('y-sel').value;
6069 var xMode = document.getElementById('x-sel').value;
6070
6071 // Filter for tag/release mode
6072 var pts = data;
6073 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
6074
6075 // Sort oldest-first for the line chart
6076 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6077
6078 var wrap = document.getElementById('chart-wrap');
6079 if(!pts.length){{
6080 var emptyMsg = (xMode === 'tag')
6081 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
6082 : 'No scan data found for the selected filters.';
6083 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
6084 renderTable([]);
6085 return;
6086 }}
6087
6088 var scaleEl=document.getElementById('scale-sel');
6089 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
6090 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;
6091 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
6092
6093 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6094
6095 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">';
6096 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>';
6097
6098 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
6099
6100 // Grid + Y axis ticks
6101 for(var ti=0;ti<=5;ti++){{
6102 var gy=PT+CH-Math.round(ti/5*CH);
6103 var gv=Math.round(ti/5*maxY);
6104 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
6105 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
6106 }}
6107
6108 // X axis labels (every N-th point to avoid crowding)
6109 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6110 pts.forEach(function(d,i){{
6111 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6112 if(i%labelEvery===0||i===pts.length-1){{
6113 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)));
6114 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>';
6115 }}
6116 }});
6117
6118 // Axis label
6119 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6120 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>';
6121 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>';
6122
6123 // Area fill + line path
6124 var pathD='';
6125 pts.forEach(function(d,i){{
6126 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6127 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6128 pathD+=(i===0?'M':'L')+x+','+y;
6129 }});
6130 if(pts.length>1){{
6131 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6132 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6133 }}
6134 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6135
6136 // Data points (clickable) + permanent value labels
6137 var showLabels = pts.length <= 40;
6138 var labelEveryN = pts.length > 20 ? 2 : 1;
6139 pts.forEach(function(d,i){{
6140 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6141 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6142 var hasTags=d.tags&&d.tags.length>0;
6143 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6144 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6145 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+'"/>';
6146 if(showLabels && i%labelEveryN===0){{
6147 var lx=x, ly=y-r-5;
6148 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>';
6149 }}
6150 }});
6151
6152 svg+='</svg>';
6153 wrap.innerHTML=svg;
6154
6155 // Attach point tooltips
6156 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6157 c.addEventListener('mouseover',function(e){{
6158 var d=pts[parseInt(this.dataset.idx)];
6159 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(''):'';
6160 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>':'';
6161 showTT(e,
6162 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6163 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6164 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6165 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6166 );
6167 this.setAttribute('r','8');
6168 }});
6169 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6170 c.addEventListener('mousemove',moveTT);
6171 c.addEventListener('click',function(){{
6172 var d=pts[parseInt(this.dataset.idx)];
6173 if(d.html_url) window.open(d.html_url,'_blank');
6174 }});
6175 }});
6176
6177 renderTable(pts, yKey);
6178 }}
6179
6180 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6181 var shProjFilter='', shBranchFilter='';
6182
6183 function fmtPST(isoStr){{
6184 if(!isoStr)return'';
6185 var d=new Date(isoStr);
6186 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6187 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);}}
6188 function p(n){{return n<10?'0'+n:String(n);}}
6189 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++;}}}}
6190 var yr=d.getUTCFullYear();
6191 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6192 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6193 var isDST=d>=dstStart&&d<dstEnd;
6194 var off=isDST?-7*3600*1000:-8*3600*1000;
6195 var lbl=isDST?'PDT':'PST';
6196 var loc=new Date(d.getTime()+off);
6197 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6198 }}
6199
6200 function getShRows(){{
6201 var proj=shProjFilter.toLowerCase().trim();
6202 var branch=shBranchFilter;
6203 return shData.filter(function(d){{
6204 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6205 if(branch&&(d.branch||'')!==branch)return false;
6206 return true;
6207 }});
6208 }}
6209
6210 function renderShPage(){{
6211 var filtered=getShRows();
6212 if(shSortCol){{
6213 filtered.sort(function(a,b){{
6214 var va,vb;
6215 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6216 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6217 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6218 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6219 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6220 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6221 }});
6222 }}
6223 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6224 shPage=Math.min(shPage,totalPages);
6225 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6226 var visible=filtered.slice(start,end);
6227 var tbody=document.getElementById('sh-tbody');
6228 if(!tbody)return;
6229 tbody.innerHTML=visible.map(function(d){{
6230 var tsHtml=esc(fmtPST(d.timestamp));
6231 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>';
6232 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>';
6233 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
6234 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
6235 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6236 var reportCell='';
6237 if(d.html_url){{
6238 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6239 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>';}}
6240 reportCell+='</div>';
6241 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
6242 if(d.submodule_links&&d.submodule_links.length){{
6243 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6244 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6245 reportCell+='</div></details>';
6246 }}
6247 return '<tr>'
6248 +'<td>'+tsHtml+'</td>'
6249 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6250 +'<td>'+runIdHtml+'</td>'
6251 +'<td>'+commitHtml+'</td>'
6252 +'<td>'+branchHtml+'</td>'
6253 +'<td>'+tags+'</td>'
6254 +'<td class="num">'+metricHtml+'</td>'
6255 +'<td class="report-cell">'+reportCell+'</td>'
6256 +'</tr>';
6257 }}).join('');
6258 var pgRange=document.getElementById('sh-pg-range');
6259 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6260 var pgInfo=document.getElementById('sh-pg-info');
6261 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6262 var pgBtns=document.getElementById('sh-pg-btns');
6263 if(pgBtns){{
6264 pgBtns.innerHTML='';
6265 function mkPgBtn(lbl,pg,active,disabled){{
6266 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6267 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6268 return b;
6269 }}
6270 pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6271 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6272 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6273 pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6274 }}
6275 }}
6276
6277 function wireTableBehavior(){{
6278 var pf=document.getElementById('sh-proj-filter');
6279 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6280 var bf=document.getElementById('sh-branch-filter');
6281 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6282 var rb=document.getElementById('sh-reset-btn');
6283 if(rb)rb.addEventListener('click',function(){{
6284 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6285 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6286 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6287 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');}});
6288 renderShPage();
6289 }});
6290 var pps=document.getElementById('sh-per-page');
6291 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6292 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6293 ths.forEach(function(th){{
6294 th.addEventListener('click',function(e){{
6295 if(e.target.classList.contains('col-resize-handle'))return;
6296 var col=th.dataset.col;
6297 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6298 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6299 th.classList.add('sort-'+shSortOrder);
6300 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6301 shPage=1;renderShPage();
6302 }});
6303 }});
6304 var table=document.getElementById('scan-history-table');
6305 if(!table)return;
6306 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6307 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6308 allThs.forEach(function(th,i){{
6309 var handle=th.querySelector('.col-resize-handle');
6310 if(!handle||!cols[i])return;
6311 var startX,startW;
6312 handle.addEventListener('mousedown',function(e){{
6313 e.stopPropagation();e.preventDefault();
6314 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6315 handle.classList.add('dragging');
6316 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6317 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6318 document.addEventListener('mousemove',onMove);
6319 document.addEventListener('mouseup',onUp);
6320 }});
6321 }});
6322 }}
6323
6324 function renderTable(pts, yKey){{
6325 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6326 var wrap=document.getElementById('data-table-wrap');
6327 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6328 var yLabel=Y_LABELS[yKey]||yKey||'';
6329 shData=pts.slice().reverse();
6330 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6331 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6332 var branches={{}};
6333 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6334 var branchOpts='<option value="">All branches</option>';
6335 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6336 wrap.innerHTML=
6337 '<div class="chart-section-header">SCAN HISTORY</div>'+
6338 '<div class="filter-row">'+
6339 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6340 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6341 '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6342 '</div>'+
6343 '<div class="table-wrap">'+
6344 '<table id="scan-history-table" class="data-table">'+
6345 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6346 '<thead><tr id="sh-thead">'+
6347 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6348 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6349 '<th>Run ID<div class="col-resize-handle"></div></th>'+
6350 '<th>Commit<div class="col-resize-handle"></div></th>'+
6351 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6352 '<th>Tags<div class="col-resize-handle"></div></th>'+
6353 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6354 '<th>Report<div class="col-resize-handle"></div></th>'+
6355 '</tr></thead>'+
6356 '<tbody id="sh-tbody"></tbody>'+
6357 '</table>'+
6358 '</div>'+
6359 '<div class="pagination">'+
6360 '<span class="pagination-info" id="sh-pg-info"></span>'+
6361 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6362 '<div style="display:flex;align-items:center;gap:8px;">'+
6363 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6364 '<select class="filter-select" id="sh-per-page">'+
6365 '<option value="10">10 per page</option>'+
6366 '<option value="25" selected>25 per page</option>'+
6367 '<option value="50">50 per page</option>'+
6368 '<option value="100">100 per page</option>'+
6369 '</select>'+
6370 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6371 '</div>'+
6372 '</div>';
6373 wireTableBehavior();
6374 renderShPage();
6375 }}
6376
6377 function exportXLSX(){{
6378 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6379 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6380 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6381 var s1R=sorted.map(function(d){{
6382 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||''];
6383 }});
6384 var pm={{}};
6385 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6386 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'];
6387 var s2R=Object.keys(pm).map(function(p){{
6388 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6389 var lat=sc[sc.length-1],fst=sc[0];
6390 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6391 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);
6392 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];
6393 }});
6394 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6395 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6396 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6397 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6398 }}
6399
6400 function buildXLSX(sheets,chartRows,chartRows2){{
6401 function s2b(s){{return new TextEncoder().encode(s);}}
6402 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
6403 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;}}
6404 function crc32(d){{
6405 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;}}}}
6406 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6407 }}
6408 function buildSheet(hdr,rows,drawRid,withCtrl){{
6409 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6410 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6411 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6412 x+='<row r="1">';
6413 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6414 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>';}}
6415 x+='</row>';
6416 rows.forEach(function(row,ri){{
6417 var rn=ri+2;
6418 x+='<row r="'+rn+'">';
6419 row.forEach(function(cell,ci){{
6420 var addr=col2l(ci+1)+rn;
6421 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6422 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6423 }});
6424 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>';}}
6425 x+='</row>';
6426 }});
6427 x+='</sheetData>';
6428 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>';}}
6429 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6430 return x+'</worksheet>';
6431 }}
6432 function buildChartXML(rows){{
6433 var sn="'Scan History'";
6434 var nr=rows.length,er=nr+1;
6435 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'}}];
6436 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6437 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">';
6438 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6439 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6440 sd.forEach(function(s,i){{
6441 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6442 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>';
6443 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6444 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>';
6445 var dlp=(i===2)?'b':'t';
6446 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>';
6447 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6448 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6449 x+='</c:strCache></c:strRef></c:cat>';
6450 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+'"/>';
6451 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6452 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6453 }});
6454 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6455 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>';
6456 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>';
6457 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6458 return x;
6459 }}
6460 function buildChartXML2(rows){{
6461 var sn="'By Project'";
6462 var nr=rows.length,er=nr+1;
6463 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'}}];
6464 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6465 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">';
6466 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6467 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6468 sd.forEach(function(s,i){{
6469 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6470 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>';
6471 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6472 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>';
6473 var dlp=(i===2)?'b':'t';
6474 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>';
6475 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6476 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6477 x+='</c:strCache></c:strRef></c:cat>';
6478 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+'"/>';
6479 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6480 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6481 }});
6482 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6483 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>';
6484 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>';
6485 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6486 return x;
6487 }}
6488 function buildChartXML3(rows){{
6489 var sn="'Scan History'";
6490 var nr=rows.length,er=nr+1;
6491 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6492 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">';
6493 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6494 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6495 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6496 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>';
6497 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6498 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>';
6499 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>';
6500 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6501 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6502 x+='</c:strCache></c:strRef></c:cat>';
6503 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+'"/>';
6504 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6505 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6506 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6507 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>';
6508 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>';
6509 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>';
6510 return x;
6511 }}
6512 var hasChart=!!(chartRows&&chartRows.length);
6513 var nr=hasChart?chartRows.length:0;
6514 var hasChart2=!!(chartRows2&&chartRows2.length);
6515 var nr2=hasChart2?chartRows2.length:0;
6516 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>';
6517 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"/>';
6518 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6519 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"/>';}}
6520 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"/>';}}
6521 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6522 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>';
6523 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6524 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"/>';}});
6525 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6526 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>';
6527 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6528 wbx+='</sheets></workbook>';
6529 var files=[
6530 {{name:'[Content_Types].xml',data:s2b(ct)}},
6531 {{name:'_rels/.rels',data:s2b(dotrels)}},
6532 {{name:'xl/workbook.xml',data:s2b(wbx)}},
6533 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6534 {{name:'xl/styles.xml',data:s2b(styl)}}
6535 ];
6536 // Chart embedded directly in Scan History (sheet1); By Project is plain
6537 sheets.forEach(function(s,i){{
6538 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)))}});
6539 }});
6540 if(hasChart){{
6541 var fromRow=nr+4,toRow=nr+24;
6542 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>')}});
6543 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6544 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">';
6545 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6546 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>';
6547 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>';
6548 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6549 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6550 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6551 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6552 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6553 var focRow=toRow+2,focRowEnd=toRow+22;
6554 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6555 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>';
6556 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>';
6557 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6558 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6559 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6560 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6561 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6562 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6563 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>')}});
6564 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6565 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6566 }}
6567 if(hasChart2){{
6568 var fromRow2=nr2+4,toRow2=nr2+24;
6569 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>')}});
6570 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6571 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">';
6572 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6573 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>';
6574 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>';
6575 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6576 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6577 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6578 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6579 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6580 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6581 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>')}});
6582 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6583 }}
6584 var parts=[],offsets=[],total=0;
6585 files.forEach(function(f){{
6586 offsets.push(total);
6587 var nb=s2b(f.name),crc=crc32(f.data);
6588 var h=new DataView(new ArrayBuffer(30+nb.length));
6589 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6590 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6591 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6592 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6593 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6594 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6595 total+=30+nb.length+f.data.length;
6596 }});
6597 var cdStart=total;
6598 files.forEach(function(f,fi){{
6599 var nb=s2b(f.name),crc=crc32(f.data);
6600 var cd=new DataView(new ArrayBuffer(46+nb.length));
6601 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6602 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6603 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6604 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6605 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6606 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6607 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6608 }});
6609 var cdSz=total-cdStart;
6610 var eocd=new DataView(new ArrayBuffer(22));
6611 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6612 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6613 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6614 parts.push(new Uint8Array(eocd.buffer));
6615 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6616 var out=new Uint8Array(sz);var off=0;
6617 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6618 return out.buffer;
6619 }}
6620
6621 function exportPNG(){{
6622 var svgEl=document.querySelector('#chart-wrap svg');
6623 if(!svgEl){{alert('No chart to export yet.');return;}}
6624 var svgStr=new XMLSerializer().serializeToString(svgEl);
6625 var vb=svgEl.viewBox.baseVal,scale=2;
6626 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6627 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6628 var url=URL.createObjectURL(blob);
6629 var img=new Image();
6630 img.onload=function(){{
6631 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6632 var ctx=canvas.getContext('2d');
6633 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6634 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6635 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6636 URL.revokeObjectURL(url);
6637 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6638 }};
6639 img.src=url;
6640 }}
6641
6642 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6643 var el=document.getElementById(id);
6644 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6645 }});
6646 rootSel.addEventListener('change',function(){{
6647 populateSubmodules(rootSel.value);
6648 loadAndRender();
6649 }});
6650 if(subSel)subSel.addEventListener('change',loadAndRender);
6651
6652 var xlsxBtn=document.getElementById('export-xlsx-btn');
6653 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6654 var pngBtn=document.getElementById('export-png-btn');
6655 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6656
6657 populateSubmodules(rootSel.value);
6658 loadAndRender();
6659
6660 (function randomizeWatermarks() {{
6661 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6662 if (!wms.length) return;
6663 var placed = [];
6664 function tooClose(top, left) {{
6665 for (var i = 0; i < placed.length; i++) {{
6666 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6667 if (dt < 16 && dl < 12) return true;
6668 }}
6669 return false;
6670 }}
6671 function pick(leftBand) {{
6672 for (var attempt = 0; attempt < 50; attempt++) {{
6673 var top = Math.random() * 88 + 2;
6674 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6675 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6676 }}
6677 var top = Math.random() * 88 + 2;
6678 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6679 placed.push([top, left]); return [top, left];
6680 }}
6681 var half = Math.floor(wms.length / 2);
6682 wms.forEach(function (img, i) {{
6683 var pos = pick(i < half);
6684 var size = Math.floor(Math.random() * 100 + 120);
6685 var rot = (Math.random() * 360).toFixed(1);
6686 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6687 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;
6688 }});
6689 }})();
6690 (function spawnCodeParticles() {{
6691 var container = document.getElementById('code-particles');
6692 if (!container) return;
6693 var snippets = [
6694 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6695 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6696 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6697 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6698 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6699 ];
6700 var count = 38;
6701 for (var i = 0; i < count; i++) {{
6702 (function(idx) {{
6703 var el = document.createElement('span');
6704 el.className = 'code-particle';
6705 el.textContent = snippets[idx % snippets.length];
6706 var left = Math.random() * 94 + 2;
6707 var top = Math.random() * 88 + 6;
6708 var dur = (Math.random() * 10 + 9).toFixed(1);
6709 var delay = (Math.random() * 18).toFixed(1);
6710 var rot = (Math.random() * 26 - 13).toFixed(1);
6711 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6712 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6713 container.appendChild(el);
6714 }})(i);
6715 }}
6716 }})();
6717 </script>
6718 <footer class="site-footer">
6719 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
6720 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6721 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6722 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6723 · <a href="/api-docs" rel="noopener">REST API</a>
6724 </footer>
6725</body>
6726</html>"##,
6727 );
6728
6729 Html(html).into_response()
6730}
6731
6732#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6735 use std::collections::HashMap;
6737 let mut langs: Vec<&sloc_core::LanguageSummary> = run
6738 .totals_by_language
6739 .iter()
6740 .filter(|l| l.test_count > 0)
6741 .collect();
6742 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6743 let lang_tests: Vec<serde_json::Value> = langs
6744 .iter()
6745 .map(|l| {
6746 let d = if l.code_lines > 0 {
6747 l.test_count as f64 / l.code_lines as f64 * 1000.0
6748 } else {
6749 0.0
6750 };
6751 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6752 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6753 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6754 })
6755 .collect();
6756 let has_file_cov = run.per_file_records.iter().any(|f| f.coverage.is_some());
6757 let cov_arr: Vec<serde_json::Value> = if has_file_cov {
6758 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6759 for rec in &run.per_file_records {
6760 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6761 let e = totals.entry(lang.display_name().to_string()).or_default();
6762 e.0 += u64::from(cov.lines_found);
6763 e.1 += u64::from(cov.lines_hit);
6764 }
6765 }
6766 let mut pairs: Vec<(String, f64)> = totals
6767 .into_iter()
6768 .filter(|(_, (found, _))| *found > 0)
6769 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6770 .collect();
6771 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6772 pairs
6773 .iter()
6774 .map(
6775 |(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}),
6776 )
6777 .collect()
6778 } else {
6779 vec![]
6780 };
6781 let (mut high, mut mid, mut low) = (0u64, 0u64, 0u64);
6782 for rec in &run.per_file_records {
6783 if let Some(cov) = &rec.coverage {
6784 if cov.lines_found == 0 {
6785 continue;
6786 }
6787 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6788 if pct >= 80.0 {
6789 high += 1;
6790 } else if pct >= 50.0 {
6791 mid += 1;
6792 } else {
6793 low += 1;
6794 }
6795 }
6796 }
6797 let t = &run.summary_totals;
6798 let total_tests = t.test_count;
6799 let density = if t.code_lines > 0 {
6800 total_tests as f64 / t.code_lines as f64 * 1000.0
6801 } else {
6802 0.0
6803 };
6804 let most_tested = langs.first().map_or_else(
6805 || "\u{2014}".to_string(),
6806 |l| l.language.display_name().to_string(),
6807 );
6808 let test_files: u64 = run
6809 .per_file_records
6810 .iter()
6811 .filter(|f| f.raw_line_categories.test_count > 0)
6812 .count() as u64;
6813 let cov_line = if t.coverage_lines_found > 0 {
6814 format!(
6815 "{:.1}",
6816 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6817 )
6818 } else {
6819 "0".to_string()
6820 };
6821 let cov_fn = if t.coverage_functions_found > 0 {
6822 format!(
6823 "{:.1}",
6824 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6825 )
6826 } else {
6827 "0".to_string()
6828 };
6829 let cov_branch = if t.coverage_branches_found > 0 {
6830 format!(
6831 "{:.1}",
6832 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6833 )
6834 } else {
6835 "0".to_string()
6836 };
6837 let has_cov = !cov_arr.is_empty();
6838 let mut file_cov_arr: Vec<serde_json::Value> = run
6839 .per_file_records
6840 .iter()
6841 .filter_map(|rec| {
6842 rec.coverage.as_ref().map(|cov| {
6843 let line_pct = if cov.lines_found > 0 {
6844 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6845 / 10.0
6846 } else {
6847 0.0
6848 };
6849 let fn_pct = if cov.functions_found > 0 {
6850 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6851 .round()
6852 / 10.0
6853 } else {
6854 -1.0
6855 };
6856 serde_json::json!({
6857 "rel": rec.relative_path,
6858 "lang": rec.language.map_or("?", |l| l.display_name()),
6859 "line_pct": line_pct,
6860 "fn_pct": fn_pct,
6861 "lhit": cov.lines_hit,
6862 "lfound": cov.lines_found,
6863 "fhit": cov.functions_hit,
6864 "ffound": cov.functions_found,
6865 })
6866 })
6867 })
6868 .collect();
6869 file_cov_arr.sort_by(|a, b| {
6870 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
6871 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
6872 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
6873 });
6874 serde_json::json!({
6875 "totals": {
6876 "test_count": total_tests,
6877 "assertions": t.test_assertion_count,
6878 "suites": t.test_suite_count,
6879 "test_files": test_files,
6880 "total_files": t.files_analyzed,
6881 "density_str": format!("{density:.1}"),
6882 "most_tested": most_tested,
6883 "langs_with_tests": langs.len(),
6884 "cov_line": cov_line,
6885 "cov_fn": cov_fn,
6886 "cov_branch": cov_branch,
6887 },
6888 "lang_tests": lang_tests,
6889 "cov": cov_arr,
6890 "cov_tiers": {"high": high, "mid": mid, "low": low},
6891 "file_cov": file_cov_arr,
6892 "has_coverage": has_cov,
6893 "submodules": {},
6894 })
6895}
6896
6897#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
6899 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
6900 .language_summaries
6901 .iter()
6902 .filter(|l| l.test_count > 0)
6903 .collect();
6904 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6905 let lang_tests: Vec<serde_json::Value> = langs
6906 .iter()
6907 .map(|l| {
6908 let d = if l.code_lines > 0 {
6909 l.test_count as f64 / l.code_lines as f64 * 1000.0
6910 } else {
6911 0.0
6912 };
6913 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6914 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6915 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6916 })
6917 .collect();
6918 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
6919 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
6920 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
6921 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
6922 let density = if sub.code_lines > 0 {
6923 total_tests as f64 / sub.code_lines as f64 * 1000.0
6924 } else {
6925 0.0
6926 };
6927 let most_tested = langs.first().map_or_else(
6928 || "\u{2014}".to_string(),
6929 |l| l.language.display_name().to_string(),
6930 );
6931 serde_json::json!({
6932 "totals": {
6933 "test_count": total_tests,
6934 "assertions": total_assertions,
6935 "suites": total_suites,
6936 "test_files": test_files_approx,
6937 "total_files": sub.files_analyzed,
6938 "density_str": format!("{density:.1}"),
6939 "most_tested": most_tested,
6940 "langs_with_tests": langs.len(),
6941 "cov_line": "0",
6942 "cov_fn": "0",
6943 "cov_branch": "0",
6944 },
6945 "lang_tests": lang_tests,
6946 "cov": [],
6947 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
6948 "has_coverage": false,
6949 })
6950}
6951
6952#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
6956 State(state): State<AppState>,
6958 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6959) -> Response {
6960 auto_scan_watched_dirs(&state).await;
6961 let watched_dirs_list: Vec<String> = {
6962 let wd = state.watched_dirs.lock().await;
6963 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6964 };
6965 let latest_run: Option<AnalysisRun> = {
6966 let reg = state.registry.lock().await;
6967 let json_str: Option<String> = reg
6968 .entries
6969 .first()
6970 .and_then(|e| e.json_path.as_ref())
6971 .and_then(|p| std::fs::read_to_string(p).ok());
6972 drop(reg);
6973 json_str
6974 .as_deref()
6975 .and_then(|s| serde_json::from_str(s).ok())
6976 };
6977
6978 let _lang_tests_json: String = latest_run.as_ref().map_or_else(
6980 || "[]".to_string(),
6981 |r| {
6982 let mut langs: Vec<&sloc_core::LanguageSummary> = r
6983 .totals_by_language
6984 .iter()
6985 .filter(|l| l.test_count > 0)
6986 .collect();
6987 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6988 let parts: Vec<String> = langs
6989 .iter()
6990 .map(|l| {
6991 let name = l.language.display_name().replace('"', "\\\"");
6992 let density = if l.code_lines > 0 {
6993 #[allow(clippy::cast_precision_loss)]
6995 { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
6996 } else {
6997 0.0
6998 };
6999 format!(
7000 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
7001 name = name,
7002 t = l.test_count,
7003 a = l.test_assertion_count,
7004 s = l.test_suite_count,
7005 c = l.code_lines,
7006 d = density,
7007 f = l.files,
7008 )
7009 })
7010 .collect();
7011 format!("[{}]", parts.join(","))
7012 },
7013 );
7014
7015 let cov_json: String = match &latest_run {
7017 Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7018 use std::collections::HashMap;
7019 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7020 for rec in &r.per_file_records {
7021 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7022 let e = totals.entry(lang.display_name().to_string()).or_default();
7023 e.0 += u64::from(cov.lines_found);
7024 e.1 += u64::from(cov.lines_hit);
7025 }
7026 }
7027 let mut pairs: Vec<(String, f64)> = totals
7028 .into_iter()
7029 .filter(|(_, (found, _))| *found > 0)
7030 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7031 .collect();
7032 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7033 let parts: Vec<String> = pairs
7034 .iter()
7035 .map(|(lang, pct)| {
7036 let name = lang.replace('"', "\\\"");
7037 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
7038 })
7039 .collect();
7040 format!("[{}]", parts.join(","))
7041 }
7042 _ => "[]".to_string(),
7043 };
7044
7045 let _cov_tier_json: String = match &latest_run {
7047 Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7048 let mut high = 0u64; let mut mid = 0u64; let mut low = 0u64; for rec in &r.per_file_records {
7052 if let Some(cov) = &rec.coverage {
7053 if cov.lines_found == 0 {
7054 continue;
7055 }
7056 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7057 if pct >= 80.0 {
7058 high += 1;
7059 } else if pct >= 50.0 {
7060 mid += 1;
7061 } else {
7062 low += 1;
7063 }
7064 }
7065 }
7066 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
7067 }
7068 _ => r#"{"high":0,"mid":0,"low":0}"#.to_string(),
7069 };
7070
7071 let total_tests: u64 = latest_run
7072 .as_ref()
7073 .map_or(0, |r| r.summary_totals.test_count);
7074 let total_assertions: u64 = latest_run
7075 .as_ref()
7076 .map_or(0, |r| r.summary_totals.test_assertion_count);
7077 let total_suites: u64 = latest_run
7078 .as_ref()
7079 .map_or(0, |r| r.summary_totals.test_suite_count);
7080 let total_code: u64 = latest_run
7081 .as_ref()
7082 .map_or(0, |r| r.summary_totals.code_lines);
7083 let workspace_density: f64 = if total_code > 0 {
7084 total_tests as f64 / total_code as f64 * 1000.0
7085 } else {
7086 0.0
7087 };
7088 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
7089 r.totals_by_language
7090 .iter()
7091 .filter(|l| l.test_count > 0)
7092 .count()
7093 });
7094 let most_tested: String = latest_run
7095 .as_ref()
7096 .and_then(|r| {
7097 r.totals_by_language
7098 .iter()
7099 .filter(|l| l.test_count > 0)
7100 .max_by_key(|l| l.test_count)
7101 })
7102 .map_or_else(
7103 || "\u{2014}".to_string(),
7104 |l| l.language.display_name().to_string(),
7105 );
7106 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7107 r.per_file_records
7108 .iter()
7109 .filter(|f| f.raw_line_categories.test_count > 0)
7110 .count() as u64
7111 });
7112 let total_files_analyzed: u64 = latest_run
7113 .as_ref()
7114 .map_or(0, |r| r.summary_totals.files_analyzed);
7115 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7116
7117 let cov_line_pct_str: String = latest_run
7119 .as_ref()
7120 .filter(|r| r.summary_totals.coverage_lines_found > 0)
7121 .map_or_else(
7122 || "0".to_string(),
7123 |r| {
7124 format!(
7125 "{:.1}",
7126 r.summary_totals.coverage_lines_hit as f64
7127 / r.summary_totals.coverage_lines_found as f64
7128 * 100.0
7129 )
7130 },
7131 );
7132 let cov_fn_pct_str: String = latest_run
7133 .as_ref()
7134 .filter(|r| r.summary_totals.coverage_functions_found > 0)
7135 .map_or_else(
7136 || "0".to_string(),
7137 |r| {
7138 format!(
7139 "{:.1}",
7140 r.summary_totals.coverage_functions_hit as f64
7141 / r.summary_totals.coverage_functions_found as f64
7142 * 100.0
7143 )
7144 },
7145 );
7146 let cov_branch_pct_str: String = latest_run
7147 .as_ref()
7148 .filter(|r| r.summary_totals.coverage_branches_found > 0)
7149 .map_or_else(
7150 || "0".to_string(),
7151 |r| {
7152 format!(
7153 "{:.1}",
7154 r.summary_totals.coverage_branches_hit as f64
7155 / r.summary_totals.coverage_branches_found as f64
7156 * 100.0
7157 )
7158 },
7159 );
7160
7161 let cov_no_data_notice = if has_coverage {
7162 String::new()
7163 } else {
7164 String::from(
7165 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7166<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>
7167<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7168 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7169 <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>
7170 <span style="color:var(--muted);font-size:12px;">·</span>
7171 <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>
7172 <span style="color:var(--muted);font-size:12px;">·</span>
7173 <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>
7174</div>
7175<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7176</div>"#,
7177 )
7178 };
7179
7180 let workspace_density_str = format!("{workspace_density:.1}");
7181 let nonce = &csp_nonce;
7182 let version = env!("CARGO_PKG_VERSION");
7183
7184 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7185 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7186 .to_string()
7187 } else {
7188 watched_dirs_list
7189 .iter()
7190 .fold(String::new(), |mut s, d| {
7191 use std::fmt::Write as _;
7192 let escaped =
7193 d.replace('&', "&").replace('"', """).replace('<', "<");
7194 write!(
7195 s,
7196 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>"#
7197 ).expect("write to String is infallible");
7198 s
7199 })
7200 };
7201 let watched_dirs_html = format!(
7202 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>"#
7203 );
7204
7205 let scope_data_json: String = {
7207 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7208 scope_map.insert(
7209 "__all__".to_string(),
7210 latest_run.as_ref().map_or_else(
7211 || {
7212 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7213 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7214 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7215 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7216 "has_coverage":false,"submodules":{}})
7217 },
7218 build_test_scope_entry,
7219 ),
7220 );
7221 let all_roots: Vec<String> = {
7222 let reg = state.registry.lock().await;
7223 let mut seen = std::collections::BTreeSet::new();
7224 reg.entries
7225 .iter()
7226 .flat_map(|e| e.input_roots.iter().cloned())
7227 .filter(|r| seen.insert(r.clone()))
7228 .collect()
7229 };
7230 for root in &all_roots {
7231 let run_for_root: Option<AnalysisRun> = {
7232 let reg = state.registry.lock().await;
7233 let json_str = reg
7234 .entries
7235 .iter()
7236 .find(|e| e.input_roots.iter().any(|r| r == root))
7237 .and_then(|e| e.json_path.as_ref())
7238 .and_then(|p| std::fs::read_to_string(p).ok());
7239 drop(reg);
7240 json_str
7241 .as_deref()
7242 .and_then(|s| serde_json::from_str(s).ok())
7243 };
7244 if let Some(ref run) = run_for_root {
7245 let mut root_entry = build_test_scope_entry(run);
7246 if !run.submodule_summaries.is_empty() {
7247 let subs: serde_json::Map<String, serde_json::Value> = run
7248 .submodule_summaries
7249 .iter()
7250 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
7251 .collect();
7252 root_entry["submodules"] = serde_json::Value::Object(subs);
7253 }
7254 scope_map.insert(root.clone(), root_entry);
7255 }
7256 }
7257 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7258 };
7259
7260 let html = format!(
7261 r#"<!doctype html>
7262<html lang="en">
7263<head>
7264 <meta charset="utf-8" />
7265 <meta name="viewport" content="width=device-width, initial-scale=1" />
7266 <title>OxideSLOC | Test Metrics</title>
7267 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7268 <style nonce="{nonce}">
7269 :root {{
7270 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7271 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7272 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7273 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7274 --info-bg:#eef3ff; --info-text:#4467d8;
7275 }}
7276 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7277 *{{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);}}
7278 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7279 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7280 .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;}}
7281 @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));}}}}
7282 .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);}}
7283 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7284 .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));}}
7285 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7286 .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;}}
7287 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7288 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7289 @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; }} }}
7290 .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;}}
7291 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7292 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7293 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7294 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7295 .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;}}
7296 .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;}}
7297 .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;}}
7298 .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;}}
7299 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7300 .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);}}
7301 .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;}}
7302 .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;}}
7303 .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;}}
7304 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7305 .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;}}
7306 .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);}}
7307 .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;}}
7308 .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;}}
7309 .tz-select:focus{{border-color:var(--oxide);}}
7310 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7311 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7312 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7313 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7314 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7315 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7316 .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;}}
7317 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7318 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7319 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7320 .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;}}
7321 .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;}}
7322 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7323 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7324 .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);}}
7325 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7326 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7327 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7328 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7329 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7330 .chart-canvas-wrap{{position:relative;height:280px;}}
7331 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7332 .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;}}
7333 .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;}}
7334 .data-table tr:last-child td{{border-bottom:none;}}
7335 .data-table tbody tr:hover td{{background:var(--surface-2);}}
7336 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7337 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7338 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7339 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7340 .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;}}
7341 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7342 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7343 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7344 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7345 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7346 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7347 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7348 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7349 .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;}}
7350 .chart-select:focus{{border-color:var(--accent);}}
7351 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7352 .trend-canvas-wrap{{position:relative;height:260px;}}
7353 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7354 .site-footer a{{color:var(--muted);}}
7355 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7356 .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;}}
7357 .btn:hover{{background:var(--surface-2);}}
7358 .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;}}
7359 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7360 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7361 .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;}}
7362 .scope-sel:focus{{border-color:var(--accent);}}
7363 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7364 .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;}}
7365 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7366 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7367 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7368 .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;}}
7369 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7370 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7371 .watched-chip-rm:hover{{color:var(--oxide);}}
7372 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7373 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7374 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7375 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7376 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7377 .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;}}
7378 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7379 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7380 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7381 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7382 .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;}}
7383 .cov-file-search:focus{{border-color:var(--accent);}}
7384 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7385 .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;}}
7386 body.dark-theme .cov-file-search{{background:var(--surface);}}
7387 </style>
7388</head>
7389<body>
7390 <div class="background-watermarks" aria-hidden="true">
7391 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7393 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7394 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7395 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7396 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7397 </div>
7398 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7399 <div class="top-nav">
7400 <div class="top-nav-inner">
7401 <a class="brand" href="/">
7402 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7403 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7404 </a>
7405 <div class="nav-right">
7406 <a class="nav-pill" href="/">Home</a>
7407 <div class="nav-dropdown">
7408 <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>
7409 <div class="nav-dropdown-menu">
7410 <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>
7411 </div>
7412 </div>
7413 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7414 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7415 <div class="nav-dropdown">
7416 <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>
7417 <div class="nav-dropdown-menu">
7418 <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>
7419 </div>
7420 </div>
7421 <div class="server-status-wrap">
7422 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7423 <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>
7424 </div>
7425 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7426 <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>
7427 </button>
7428 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7429 <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>
7430 <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>
7431 </button>
7432 </div>
7433 </div>
7434 </div>
7435
7436 <div class="page">
7437 {watched_dirs_html}
7438 <div class="scope-bar">
7439 <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>
7440 <span class="scope-label">Scope</span>
7441 <div class="scope-sel-wrap">
7442 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7443 <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);">
7444 <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>
7445 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7446 </div>
7447 </div>
7448 </div>
7449 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7450 <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>
7451 <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>
7452 <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>
7453 <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>
7454 </div>
7455 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7456 <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>
7457 <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>
7458 <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>
7459 <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>
7460 </div>
7461
7462 <div class="panel">
7463 <h1>Test Metrics</h1>
7464 <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>
7465
7466 <div class="chart-row">
7467 <div class="chart-box">
7468 <div class="chart-box-title">Test Definitions by Language</div>
7469 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7470 </div>
7471 <div class="chart-box">
7472 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7473 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7474 </div>
7475 </div>
7476
7477 <div class="section-header">Language Breakdown</div>
7478 {cov_no_data_notice}
7479 <div style="overflow-x:auto;">
7480 <table class="data-table" id="lang-table">
7481 <thead><tr>
7482 <th>Language</th>
7483 <th class="num">Test Fns</th>
7484 <th class="num">Assertions</th>
7485 <th class="num">Suites</th>
7486 <th class="num">Code Lines</th>
7487 <th class="num">Files</th>
7488 <th class="num">Density / 1K</th>
7489 <th>Relative Density</th>
7490 </tr></thead>
7491 <tbody id="lang-tbody"></tbody>
7492 </table>
7493 </div>
7494 </div>
7495
7496 <div class="panel" id="cov-panel" style="display:none;">
7497 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7498 <div class="cov-gauge-row" id="cov-gauges">
7499 <div class="cov-gauge-card">
7500 <div class="cov-gauge-label">Line Coverage</div>
7501 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7502 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7503 <div class="cov-gauge-sub">Lines hit / instrumented</div>
7504 </div>
7505 <div class="cov-gauge-card">
7506 <div class="cov-gauge-label">Function Coverage</div>
7507 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7508 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7509 <div class="cov-gauge-sub">Functions hit / found</div>
7510 </div>
7511 <div class="cov-gauge-card">
7512 <div class="cov-gauge-label">Branch Coverage</div>
7513 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7514 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7515 <div class="cov-gauge-sub">Branches hit / found</div>
7516 </div>
7517 </div>
7518 <div class="chart-row">
7519 <div class="chart-box">
7520 <div class="chart-box-title">Line Coverage % by Language</div>
7521 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7522 </div>
7523 <div class="chart-box">
7524 <div class="chart-box-title">Coverage Tier Distribution</div>
7525 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7526 </div>
7527 </div>
7528
7529 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7530 <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>
7531 <div class="cov-file-toolbar">
7532 <div class="cov-filter-tabs" id="cov-filter-tabs">
7533 <button class="cov-tab active" data-tier="all">All</button>
7534 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7535 <button class="cov-tab" data-tier="low">Low (<50%)</button>
7536 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7537 <button class="cov-tab" data-tier="high">High (≥80%)</button>
7538 </div>
7539 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7540 </div>
7541 <div style="overflow-x:auto;">
7542 <table class="data-table" id="cov-file-table">
7543 <thead><tr>
7544 <th>File</th>
7545 <th>Lang</th>
7546 <th class="num">Line %</th>
7547 <th class="num">Lines Hit / Found</th>
7548 <th class="num">Fn %</th>
7549 <th class="num">Fns Hit / Found</th>
7550 </tr></thead>
7551 <tbody id="cov-file-tbody"></tbody>
7552 </table>
7553 </div>
7554 <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>
7555 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7556 </div>
7557
7558 <div class="panel">
7559 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7560 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7561 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7562 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7563 </div>
7564 </div>
7565
7566 <footer class="site-footer">
7567 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
7568 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7569 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7570 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7571 · <a href="/api-docs" rel="noopener">REST API</a>
7572 </footer>
7573
7574 <script nonce="{nonce}">
7575 (function() {{
7576 // Theme
7577 var b = document.body;
7578 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7579 var tgl = document.getElementById('theme-toggle');
7580 if (tgl) tgl.addEventListener('click', function() {{
7581 var d = b.classList.toggle('dark-theme');
7582 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7583 }});
7584
7585 // Watermarks
7586 (function() {{
7587 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7588 if (!wms.length) return;
7589 var placed = [];
7590 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;}}
7591 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];}}
7592 var half=Math.floor(wms.length/2);
7593 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;}});
7594 }})();
7595
7596 // Code particles
7597 (function() {{
7598 var container = document.getElementById('code-particles');
7599 if (!container) return;
7600 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7601 for (var i = 0; i < 36; i++) {{
7602 (function(idx) {{
7603 var el = document.createElement('span');
7604 el.className = 'code-particle';
7605 el.textContent = snippets[idx % snippets.length];
7606 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7607 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7608 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7609 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';
7610 container.appendChild(el);
7611 }})(i);
7612 }}
7613 }})();
7614
7615 // Settings modal
7616 (function() {{
7617 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'}}];
7618 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);}});}}
7619 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7620 var btn=document.getElementById('settings-btn');if(!btn)return;
7621 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7622 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>';
7623 document.body.appendChild(m);
7624 var g=document.getElementById('scheme-grid');
7625 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);}});
7626 var cl=document.getElementById('settings-close');
7627 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');}});
7628 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7629 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7630 }})();
7631
7632 // Watched folder picker
7633 (function() {{
7634 var btn = document.getElementById('add-watched-btn');
7635 if (!btn) return;
7636 btn.addEventListener('click', function() {{
7637 fetch('/pick-directory?kind=reports')
7638 .then(function(r) {{ return r.json(); }})
7639 .then(function(data) {{
7640 if (!data.cancelled && data.selected_path) {{
7641 var form = document.createElement('form');
7642 form.method = 'POST';
7643 form.action = '/watched-dirs/add';
7644 var ri = document.createElement('input');
7645 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7646 var fi = document.createElement('input');
7647 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7648 form.appendChild(ri); form.appendChild(fi);
7649 document.body.appendChild(form);
7650 form.submit();
7651 }}
7652 }})
7653 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7654 }});
7655 }})();
7656 }})();
7657 </script>
7658
7659 <script src="/static/chart.js" nonce="{nonce}"></script>
7660 <script nonce="{nonce}">
7661 (function() {{
7662 var SCOPE_DATA = {scope_data_json};
7663 var currentRoot = '__all__';
7664 var currentSub = '';
7665 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7666 var ALL_CHARTS = [];
7667
7668 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();}}
7669 function fmtFull(n){{return Number(n).toLocaleString();}}
7670 function isDark(){{return document.body.classList.contains('dark-theme');}}
7671 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7672 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7673 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7674
7675 function getDataset() {{
7676 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7677 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7678 return r;
7679 }}
7680 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7681
7682 function renderTestCharts(D) {{
7683 testsChart = destroyChart(testsChart);
7684 densityChart = destroyChart(densityChart);
7685 if (!D || !D.length) return;
7686 var top15 = D.slice(0, 15);
7687 var canvas1 = document.getElementById('canvas-tests');
7688 if (canvas1) {{
7689 testsChart = new Chart(canvas1, {{
7690 type: 'bar',
7691 data: {{
7692 labels: top15.map(function(d){{ return d.lang; }}),
7693 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7694 }},
7695 options: {{
7696 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7697 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7698 scales: {{
7699 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7700 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7701 }}
7702 }}
7703 }});
7704 ALL_CHARTS.push(testsChart);
7705 }}
7706 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7707 var canvas2 = document.getElementById('canvas-density');
7708 if (canvas2) {{
7709 densityChart = new Chart(canvas2, {{
7710 type: 'bar',
7711 data: {{
7712 labels: topD.map(function(d){{ return d.lang; }}),
7713 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 }}]
7714 }},
7715 options: {{
7716 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7717 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7718 scales: {{
7719 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7720 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7721 }}
7722 }}
7723 }});
7724 ALL_CHARTS.push(densityChart);
7725 }}
7726 }}
7727
7728 function renderCovCharts(covD, tiers) {{
7729 covChart = destroyChart(covChart);
7730 tierChart = destroyChart(tierChart);
7731 var covCanvas = document.getElementById('canvas-cov');
7732 if (covCanvas && covD && covD.length) {{
7733 covChart = new Chart(covCanvas, {{
7734 type: 'bar',
7735 data: {{
7736 labels: covD.map(function(d){{ return d.lang; }}),
7737 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 }}]
7738 }},
7739 options: {{
7740 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7741 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7742 scales: {{
7743 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7744 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7745 }}
7746 }}
7747 }});
7748 ALL_CHARTS.push(covChart);
7749 }}
7750 var tierCanvas = document.getElementById('canvas-cov-tiers');
7751 if (tierCanvas && tiers) {{
7752 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7753 tierChart = new Chart(tierCanvas, {{
7754 type: 'doughnut',
7755 data: {{
7756 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7757 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7758 }},
7759 options: {{
7760 responsive: true, maintainAspectRatio: false, cutout: '62%',
7761 plugins: {{
7762 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7763 tooltip: {{ callbacks: {{ label: function(ctx) {{
7764 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7765 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7766 }} }} }}
7767 }}
7768 }}
7769 }});
7770 ALL_CHARTS.push(tierChart);
7771 }}
7772 }}
7773
7774 function buildLangTable(D) {{
7775 var tbody = document.getElementById('lang-tbody');
7776 if (!tbody) return;
7777 if (!D || !D.length) {{
7778 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>';
7779 return;
7780 }}
7781 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7782 tbody.innerHTML = D.map(function(d) {{
7783 var barW = Math.round(d.density / maxDensity * 120);
7784 return '<tr>' +
7785 '<td><strong>' + d.lang + '</strong></td>' +
7786 '<td class="num">' + fmt(d.tests) + '</td>' +
7787 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7788 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7789 '<td class="num">' + fmt(d.code) + '</td>' +
7790 '<td class="num">' + fmt(d.files) + '</td>' +
7791 '<td class="num">' + d.density.toFixed(2) + '</td>' +
7792 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7793 '</tr>';
7794 }}).join('');
7795 }}
7796
7797 var covFileData = [];
7798 var covFileTier = 'all';
7799 var covFileSearch = '';
7800
7801 function pctBadge(pct) {{
7802 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7803 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7804 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7805 }}
7806
7807 function buildCovFileTable() {{
7808 var tbody = document.getElementById('cov-file-tbody');
7809 var empty = document.getElementById('cov-file-empty');
7810 var count = document.getElementById('cov-file-count');
7811 if (!tbody) return;
7812 var srch = covFileSearch.toLowerCase();
7813 var filtered = covFileData.filter(function(f) {{
7814 if (covFileTier === 'zero' && f.line_pct > 0) return false;
7815 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7816 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7817 if (covFileTier === 'high' && f.line_pct < 80) return false;
7818 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7819 return true;
7820 }});
7821 if (!filtered.length) {{
7822 tbody.innerHTML = '';
7823 if (empty) empty.style.display = '';
7824 if (count) count.textContent = '';
7825 return;
7826 }}
7827 if (empty) empty.style.display = 'none';
7828 var shown = Math.min(filtered.length, 500);
7829 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7830 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7831 var fnCol = f.fn_pct < 0
7832 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7833 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7834 return '<tr>' +
7835 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
7836 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7837 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7838 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7839 fnCol +
7840 '</tr>';
7841 }}).join('');
7842 }}
7843
7844 (function() {{
7845 var tabs = document.getElementById('cov-filter-tabs');
7846 if (tabs) {{
7847 tabs.addEventListener('click', function(e) {{
7848 var btn = e.target.closest('.cov-tab');
7849 if (!btn) return;
7850 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7851 btn.classList.add('active');
7852 covFileTier = btn.getAttribute('data-tier');
7853 buildCovFileTable();
7854 }});
7855 }}
7856 var srch = document.getElementById('cov-file-search');
7857 if (srch) {{
7858 srch.addEventListener('input', function() {{
7859 covFileSearch = this.value;
7860 buildCovFileTable();
7861 }});
7862 }}
7863 }})();
7864
7865 function updateCovGauges(t) {{
7866 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
7867 var el;
7868 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
7869 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
7870 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
7871 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
7872 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
7873 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
7874 }}
7875
7876 function applyScope() {{
7877 var d = getDataset();
7878 var t = d.totals;
7879 var el;
7880 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
7881 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
7882 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
7883 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
7884 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
7885 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
7886 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
7887 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
7888 renderTestCharts(d.lang_tests);
7889 buildLangTable(d.lang_tests);
7890 var covPanel = document.getElementById('cov-panel');
7891 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
7892 if (d.has_coverage) {{
7893 renderCovCharts(d.cov, d.cov_tiers);
7894 updateCovGauges(t);
7895 covFileData = d.file_cov || [];
7896 covFileTier = 'all';
7897 covFileSearch = '';
7898 var tabs = document.getElementById('cov-filter-tabs');
7899 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
7900 var srch = document.getElementById('cov-file-search');
7901 if (srch) srch.value = '';
7902 buildCovFileTable();
7903 }}
7904 loadTrend();
7905 }}
7906
7907 // Populate scope-root-sel from SCOPE_DATA keys
7908 (function() {{
7909 var sel = document.getElementById('scope-root-sel');
7910 if (!sel) return;
7911 Object.keys(SCOPE_DATA).forEach(function(k) {{
7912 if (k === '__all__') return;
7913 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
7914 }});
7915 }})();
7916
7917 document.getElementById('scope-root-sel').addEventListener('change', function() {{
7918 currentRoot = this.value;
7919 currentSub = '';
7920 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7921 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
7922 var subWrap = document.getElementById('scope-sub-wrap');
7923 var subSel = document.getElementById('scope-sub-sel');
7924 subSel.innerHTML = '<option value="">Entire project</option>';
7925 if (subNames.length) {{
7926 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
7927 subWrap.style.display = 'flex';
7928 }} else {{
7929 subWrap.style.display = 'none';
7930 }}
7931 applyScope();
7932 }});
7933
7934 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
7935 currentSub = this.value;
7936 applyScope();
7937 }});
7938
7939 function buildTrend(data) {{
7940 var trendCanvas = document.getElementById('canvas-trend');
7941 var trendEmpty = document.getElementById('trend-empty');
7942 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
7943 pts = pts.slice().reverse();
7944 if (!pts.length) {{
7945 if (trendCanvas) trendCanvas.style.display = 'none';
7946 if (trendEmpty) trendEmpty.style.display = '';
7947 return;
7948 }}
7949 if (trendCanvas) trendCanvas.style.display = '';
7950 if (trendEmpty) trendEmpty.style.display = 'none';
7951 trendChart = destroyChart(trendChart);
7952 if (!trendCanvas) return;
7953 trendChart = new Chart(trendCanvas, {{
7954 type: 'line',
7955 data: {{
7956 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
7957 datasets: [{{
7958 label: 'Test Definitions',
7959 data: pts.map(function(d){{ return d.test_count; }}),
7960 borderColor: '#C45C10',
7961 backgroundColor: 'rgba(196,92,16,0.10)',
7962 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
7963 pointRadius: 5, fill: true, tension: 0.3
7964 }}]
7965 }},
7966 options: {{
7967 responsive: true, maintainAspectRatio: false,
7968 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
7969 scales: {{
7970 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
7971 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
7972 }}
7973 }}
7974 }});
7975 ALL_CHARTS.push(trendChart);
7976 }}
7977
7978 function loadTrend() {{
7979 var url = '/api/metrics/history?limit=100';
7980 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
7981 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
7982 buildTrend(data);
7983 }}).catch(function(){{
7984 var trendEmpty = document.getElementById('trend-empty');
7985 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
7986 }});
7987 }}
7988
7989 // Re-render charts on theme toggle
7990 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
7991 setTimeout(function() {{
7992 ALL_CHARTS.forEach(function(c) {{
7993 if (c && c.options && c.options.scales) {{
7994 Object.values(c.options.scales).forEach(function(ax) {{
7995 if (ax.grid) ax.grid.color = clr();
7996 if (ax.ticks) ax.ticks.color = txtClr();
7997 }});
7998 c.update();
7999 }}
8000 }});
8001 }}, 80);
8002 }});
8003
8004 applyScope();
8005 }})();
8006 </script>
8007</body>
8008</html>"#,
8009 );
8010 Html(html).into_response()
8011}
8012
8013#[derive(Deserialize)]
8020struct EmbedQuery {
8021 run_id: Option<String>,
8022 theme: Option<String>,
8023}
8024
8025async fn embed_handler(
8026 State(state): State<AppState>,
8027 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8028 Query(query): Query<EmbedQuery>,
8029) -> Response {
8030 let entry = {
8031 let reg = state.registry.lock().await;
8032 query.run_id.as_ref().map_or_else(
8033 || reg.entries.first().cloned(),
8034 |id| reg.find_by_run_id(id).cloned(),
8035 )
8036 };
8037
8038 let Some(entry) = entry else {
8039 return Html(
8040 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
8041 .to_string(),
8042 )
8043 .into_response();
8044 };
8045
8046 let dark = query.theme.as_deref() == Some("dark");
8047 let languages: Vec<(String, u64, u64)> = entry
8048 .json_path
8049 .as_ref()
8050 .and_then(|p| read_json(p).ok())
8051 .map(|run| {
8052 run.totals_by_language
8053 .iter()
8054 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
8055 .collect()
8056 })
8057 .unwrap_or_default();
8058
8059 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
8060}
8061
8062fn render_embed_widget(
8063 entry: &RegistryEntry,
8064 languages: &[(String, u64, u64)],
8065 dark: bool,
8066 csp_nonce: &str,
8067) -> String {
8068 let s = &entry.summary;
8069 let total = s.code_lines + s.comment_lines + s.blank_lines;
8070 let code_pct = s
8071 .code_lines
8072 .checked_mul(100)
8073 .and_then(|n| n.checked_div(total))
8074 .unwrap_or(0);
8075
8076 let (bg, fg, surface, muted, border) = if dark {
8077 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
8078 } else {
8079 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
8080 };
8081
8082 let mut lang_rows = String::new();
8083 for (name, files, code) in languages {
8084 write!(
8085 lang_rows,
8086 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
8087 escape_html(name),
8088 format_number(*files),
8089 format_number(*code),
8090 )
8091 .ok();
8092 }
8093
8094 let lang_table = if lang_rows.is_empty() {
8095 String::new()
8096 } else {
8097 format!(
8098 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
8099 )
8100 };
8101
8102 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8103 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8104 let project_esc = escape_html(&entry.project_label);
8105 let code_lines = format_number(s.code_lines);
8106 let comment_lines = format_number(s.comment_lines);
8107 let files = format_number(s.files_analyzed);
8108 let code_raw = s.code_lines;
8109 let comment_raw = s.comment_lines;
8110 let blank_raw = s.blank_lines;
8111
8112 format!(
8113 r#"<!doctype html>
8114<html lang="en">
8115<head>
8116 <meta charset="utf-8">
8117 <meta name="viewport" content="width=device-width,initial-scale=1">
8118 <title>OxideSLOC — {project_esc}</title>
8119 <script src="/static/chart.js"></script>
8120 <style nonce="{csp_nonce}">
8121 *{{box-sizing:border-box;margin:0;padding:0}}
8122 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8123 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8124 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8125 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8126 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8127 .card .v{{font-size:18px;font-weight:700}}
8128 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8129 .row{{display:flex;gap:12px;align-items:flex-start}}
8130 .pie{{width:120px;height:120px;flex-shrink:0}}
8131 .lt{{border-collapse:collapse;width:100%;flex:1}}
8132 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8133 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8134 .n{{text-align:right}}
8135 .footer{{margin-top:10px;color:{muted};font-size:10px}}
8136 </style>
8137</head>
8138<body>
8139 <h2>{project_esc}</h2>
8140 <div class="sub">{timestamp} · run {run_short}</div>
8141 <div class="cards">
8142 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8143 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8144 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8145 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8146 </div>
8147 <div class="row">
8148 <canvas class="pie" id="c"></canvas>
8149 {lang_table}
8150 </div>
8151 <div class="footer">oxide-sloc</div>
8152 <script nonce="{csp_nonce}">
8153 new Chart(document.getElementById('c'),{{
8154 type:'doughnut',
8155 data:{{
8156 labels:['Code','Comments','Blank'],
8157 datasets:[{{
8158 data:[{code_raw},{comment_raw},{blank_raw}],
8159 backgroundColor:['#4a78ee','#b35428','#aaa'],
8160 borderWidth:0
8161 }}]
8162 }},
8163 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8164 }});
8165 </script>
8166</body>
8167</html>"#
8168 )
8169}
8170
8171#[allow(clippy::too_many_arguments)]
8172fn persist_run_artifacts(
8173 run: &sloc_core::AnalysisRun,
8174 report_html: &str,
8175 run_dir: &Path,
8176 generate_json: bool,
8177 generate_html: bool,
8178 generate_pdf: bool,
8179 report_title: &str,
8180 file_stem: &str,
8181 result_context: RunResultContext,
8182) -> Result<(RunArtifacts, PendingPdf)> {
8183 fs::create_dir_all(run_dir)
8184 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8185
8186 let mut html_path = None;
8187 let mut pdf_path = None;
8188 let mut json_path = None;
8189 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8190
8191 if generate_html {
8192 let path = run_dir.join(format!("report_{file_stem}.html"));
8193 fs::write(&path, report_html)
8194 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8195 html_path = Some(path);
8196 }
8197
8198 if generate_json {
8199 let path = run_dir.join(format!("result_{file_stem}.json"));
8200 let json = serde_json::to_string_pretty(run)
8201 .context("failed to serialize analysis run to JSON")?;
8202 fs::write(&path, json)
8203 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8204 json_path = Some(path);
8205 }
8206
8207 if generate_pdf {
8208 let source_html_path = if let Some(existing) = html_path.as_ref() {
8209 existing.clone()
8210 } else {
8211 let temp_html = run_dir.join("_report_rendered.html");
8212 fs::write(&temp_html, report_html).with_context(|| {
8213 format!(
8214 "failed to write temporary HTML report to {}",
8215 temp_html.display()
8216 )
8217 })?;
8218 temp_html
8219 };
8220
8221 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8222 let cleanup_src = !generate_html;
8223 pdf_path = Some(pdf_dest.clone());
8224 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8225 }
8226
8227 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8228
8229 Ok((
8230 RunArtifacts {
8231 output_dir: run_dir.to_path_buf(),
8232 html_path,
8233 pdf_path,
8234 json_path,
8235 scan_config_path,
8236 report_title: report_title.to_string(),
8237 result_context,
8238 },
8239 pending_pdf,
8240 ))
8241}
8242
8243fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8246 let exact = dir.join("scan-config.json");
8247 if exact.exists() {
8248 return Some(exact);
8249 }
8250 fs::read_dir(dir).ok().and_then(|entries| {
8251 entries
8252 .filter_map(std::result::Result::ok)
8253 .find(|e| {
8254 let name = e.file_name();
8255 let name = name.to_string_lossy();
8256 name.starts_with("scan-config") && name.ends_with(".json")
8257 })
8258 .map(|e| e.path())
8259 })
8260}
8261
8262async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8265 let toml_str = match toml::to_string_pretty(&state.base_config) {
8266 Ok(s) => s,
8267 Err(e) => {
8268 return (
8269 StatusCode::INTERNAL_SERVER_ERROR,
8270 format!("serialization error: {e}"),
8271 )
8272 .into_response();
8273 }
8274 };
8275 (
8276 [
8277 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8278 (
8279 header::CONTENT_DISPOSITION,
8280 "attachment; filename=\".oxide-sloc.toml\"",
8281 ),
8282 ],
8283 toml_str,
8284 )
8285 .into_response()
8286}
8287
8288#[derive(Deserialize)]
8289struct ImportConfigBody {
8290 toml: String,
8291}
8292
8293async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8294 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8295 Ok(config) => {
8296 if let Err(e) = config.validate() {
8297 return (
8298 StatusCode::UNPROCESSABLE_ENTITY,
8299 Json(serde_json::json!({ "error": e.to_string() })),
8300 )
8301 .into_response();
8302 }
8303 Json(serde_json::json!({ "ok": true, "config": config })).into_response()
8304 }
8305 Err(e) => (
8306 StatusCode::BAD_REQUEST,
8307 Json(serde_json::json!({ "error": format!("TOML parse error: {e}") })),
8308 )
8309 .into_response(),
8310 }
8311}
8312
8313async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8316 let store = state.scan_profiles.lock().await;
8317 Json(serde_json::json!({ "profiles": store.profiles }))
8318}
8319
8320#[derive(Deserialize)]
8321struct SaveScanProfileBody {
8322 name: String,
8323 params: serde_json::Value,
8324}
8325
8326async fn api_save_scan_profile(
8327 State(state): State<AppState>,
8328 Json(body): Json<SaveScanProfileBody>,
8329) -> impl IntoResponse {
8330 if body.name.trim().is_empty() {
8331 return (
8332 StatusCode::BAD_REQUEST,
8333 Json(serde_json::json!({ "error": "name must not be empty" })),
8334 )
8335 .into_response();
8336 }
8337
8338 let id = uuid::Uuid::new_v4().to_string();
8339 let profile = ScanProfile {
8340 id: id.clone(),
8341 name: body.name.trim().to_string(),
8342 created_at: chrono::Utc::now().to_rfc3339(),
8343 params: body.params,
8344 };
8345
8346 let mut store = state.scan_profiles.lock().await;
8347 store.profiles.push(profile);
8348 if let Err(e) = store.save(&state.scan_profiles_path) {
8349 tracing::warn!("failed to persist scan profiles: {e}");
8350 }
8351 drop(store);
8352
8353 (
8354 StatusCode::CREATED,
8355 Json(serde_json::json!({ "ok": true, "id": id })),
8356 )
8357 .into_response()
8358}
8359
8360async fn api_delete_scan_profile(
8361 State(state): State<AppState>,
8362 AxumPath(id): AxumPath<String>,
8363) -> impl IntoResponse {
8364 let mut store = state.scan_profiles.lock().await;
8365 let before = store.profiles.len();
8366 store.profiles.retain(|p| p.id != id);
8367 if store.profiles.len() == before {
8368 drop(store);
8369 return (
8370 StatusCode::NOT_FOUND,
8371 Json(serde_json::json!({ "error": "profile not found" })),
8372 )
8373 .into_response();
8374 }
8375 if let Err(e) = store.save(&state.scan_profiles_path) {
8376 tracing::warn!("failed to persist scan profiles: {e}");
8377 }
8378 drop(store);
8379 Json(serde_json::json!({ "ok": true })).into_response()
8380}
8381
8382fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8383 let value = raw.unwrap_or("out/web").trim();
8384 let path = if value.is_empty() {
8385 PathBuf::from("out/web")
8386 } else {
8387 PathBuf::from(value)
8388 };
8389
8390 if path.is_absolute() {
8391 path
8392 } else {
8393 workspace_root().join(path)
8394 }
8395}
8396
8397fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8399 std::env::var("SLOC_GIT_CLONES_DIR")
8400 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8401}
8402
8403pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8406 let safe: String = repo_url
8407 .chars()
8408 .map(|c| {
8409 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8410 c
8411 } else {
8412 '_'
8413 }
8414 })
8415 .take(80)
8416 .collect();
8417 clones_dir.join(safe)
8418}
8419
8420pub(crate) fn scan_path_to_artifacts(
8423 scan_path: &Path,
8424 base_config: &AppConfig,
8425 label: &str,
8426) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8427 let mut config = base_config.clone();
8428 config.discovery.root_paths = vec![scan_path.to_path_buf()];
8429 label.clone_into(&mut config.reporting.report_title);
8430 let run = analyze(&config, "git", None)?;
8431 let html = render_html(&run)?;
8432 let run_id = run.tool.run_id.clone();
8433 let project_label = sanitize_project_label(label);
8434 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8435 let file_stem = {
8436 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8437 if commit.is_empty() {
8438 project_label
8439 } else {
8440 format!("{project_label}_{commit}")
8441 }
8442 };
8443 let (artifacts, _pending_pdf) = persist_run_artifacts(
8444 &run,
8445 &html,
8446 &output_dir,
8447 true,
8448 true,
8449 false,
8450 label,
8451 &file_stem,
8452 RunResultContext::default(),
8453 )?;
8454 Ok((run_id, artifacts, run))
8455}
8456
8457async fn restart_poll_schedules(state: &AppState) {
8459 let store = state.schedules.lock().await;
8460 let poll_schedules: Vec<_> = store
8461 .schedules
8462 .iter()
8463 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8464 .cloned()
8465 .collect();
8466 drop(store);
8467 for schedule in poll_schedules {
8468 let interval = schedule.interval_secs.unwrap_or(300);
8469 let st = state.clone();
8470 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8471 }
8472}
8473
8474fn split_patterns(raw: Option<&str>) -> Vec<String> {
8475 raw.unwrap_or("")
8476 .lines()
8477 .flat_map(|line| line.split(','))
8478 .map(str::trim)
8479 .filter(|part| !part.is_empty())
8480 .map(ToOwned::to_owned)
8481 .collect()
8482}
8483
8484fn build_sub_run(
8485 parent: &AnalysisRun,
8486 sub: &sloc_core::SubmoduleSummary,
8487 parent_path: &str,
8488) -> AnalysisRun {
8489 let sub_files: Vec<_> = parent
8490 .per_file_records
8491 .iter()
8492 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8493 .cloned()
8494 .collect();
8495 let mut config = parent.effective_configuration.clone();
8496 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8497 AnalysisRun {
8498 tool: parent.tool.clone(),
8499 environment: parent.environment.clone(),
8500 effective_configuration: config,
8501 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8502 summary_totals: SummaryTotals {
8503 files_considered: sub.files_analyzed,
8504 files_analyzed: sub.files_analyzed,
8505 files_skipped: 0,
8506 total_physical_lines: sub.total_physical_lines,
8507 code_lines: sub.code_lines,
8508 comment_lines: sub.comment_lines,
8509 blank_lines: sub.blank_lines,
8510 mixed_lines_separate: 0,
8511 functions: 0,
8512 classes: 0,
8513 variables: 0,
8514 imports: 0,
8515 test_count: 0,
8516 test_assertion_count: 0,
8517 test_suite_count: 0,
8518 coverage_lines_found: 0,
8519 coverage_lines_hit: 0,
8520 coverage_functions_found: 0,
8521 coverage_functions_hit: 0,
8522 coverage_branches_found: 0,
8523 coverage_branches_hit: 0,
8524 },
8525 totals_by_language: sub.language_summaries.clone(),
8526 per_file_records: sub_files,
8527 skipped_file_records: vec![],
8528 warnings: vec![],
8529 submodule_summaries: vec![],
8530 git_commit_short: parent.git_commit_short.clone(),
8531 git_commit_long: parent.git_commit_long.clone(),
8532 git_branch: parent.git_branch.clone(),
8533 git_commit_author: parent.git_commit_author.clone(),
8534 git_commit_date: parent.git_commit_date.clone(),
8535 git_tags: parent.git_tags.clone(),
8536 git_nearest_tag: parent.git_nearest_tag.clone(),
8537 }
8538}
8539
8540pub(crate) fn sanitize_project_label(raw: &str) -> String {
8541 let candidate = Path::new(raw)
8542 .file_name()
8543 .and_then(|name| name.to_str())
8544 .unwrap_or("project");
8545
8546 let mut value = String::with_capacity(candidate.len());
8547 for ch in candidate.chars() {
8548 if ch.is_ascii_alphanumeric() {
8549 value.push(ch.to_ascii_lowercase());
8550 } else {
8551 value.push('-');
8552 }
8553 }
8554
8555 let compact = value.trim_matches('-').to_string();
8556 if compact.is_empty() {
8557 "project".to_string()
8558 } else {
8559 compact
8560 }
8561}
8562
8563fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8566 let s = path.to_string_lossy();
8567 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8568 return PathBuf::from(format!(r"\\{rest}"));
8569 }
8570 if let Some(rest) = s.strip_prefix(r"\\?\") {
8571 return PathBuf::from(rest);
8572 }
8573 path
8574}
8575
8576fn display_path(path: &Path) -> String {
8577 let s = path.to_string_lossy();
8578 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8583 return format!(r"\\{rest}");
8584 }
8585 if let Some(rest) = s.strip_prefix(r"\\?\") {
8586 return rest.to_owned();
8587 }
8588 s.into_owned()
8589}
8590
8591fn sanitize_path_str(s: &str) -> String {
8592 if let Some(rest) = s.strip_prefix("//?/UNC/") {
8596 return format!("//{rest}");
8597 }
8598 if let Some(rest) = s.strip_prefix("//?/") {
8599 return rest.to_owned();
8600 }
8601 display_path(Path::new(s))
8602}
8603
8604fn workspace_root() -> PathBuf {
8605 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8607 let p = PathBuf::from(root);
8608 if p.is_dir() {
8609 return p;
8610 }
8611 }
8612
8613 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8616}
8617
8618fn make_git_label(repo: &str, ref_name: &str) -> String {
8620 if repo.is_empty() || ref_name.is_empty() {
8621 return String::new();
8622 }
8623 let base = repo
8624 .trim_end_matches('/')
8625 .trim_end_matches(".git")
8626 .rsplit('/')
8627 .next()
8628 .unwrap_or("repo");
8629 let ref_safe: String = ref_name
8630 .chars()
8631 .map(|c| {
8632 if c.is_alphanumeric() || c == '-' || c == '.' {
8633 c
8634 } else {
8635 '_'
8636 }
8637 })
8638 .collect();
8639 format!("{base}_at_{ref_safe}_sloc")
8640}
8641
8642fn desktop_dir() -> PathBuf {
8644 if let Ok(profile) = std::env::var("USERPROFILE") {
8645 let p = PathBuf::from(profile).join("Desktop");
8646 if p.exists() {
8647 return p;
8648 }
8649 }
8650 if let Ok(home) = std::env::var("HOME") {
8651 let p = PathBuf::from(home).join("Desktop");
8652 if p.exists() {
8653 return p;
8654 }
8655 }
8656 workspace_root().join("out").join("web")
8657}
8658
8659fn resolve_input_path(raw: &str) -> PathBuf {
8660 let trimmed = raw.trim();
8661 if trimmed.is_empty() {
8662 return workspace_root().join("samples").join("basic");
8663 }
8664
8665 let candidate = PathBuf::from(trimmed);
8666 let resolved = if candidate.is_absolute() {
8667 candidate
8668 } else {
8669 let rooted = workspace_root().join(&candidate);
8670 if rooted.exists() {
8671 rooted
8672 } else {
8673 workspace_root().join(candidate)
8674 }
8675 };
8676
8677 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8680 PathBuf::from(display_path(&canonical))
8681}
8682
8683fn dir_size_bytes(path: &Path) -> u64 {
8684 let mut total = 0u64;
8685 if let Ok(rd) = fs::read_dir(path) {
8686 for entry in rd.filter_map(Result::ok) {
8687 let p = entry.path();
8688 if p.is_file() {
8689 if let Ok(meta) = p.metadata() {
8690 total += meta.len();
8691 }
8692 } else if p.is_dir() {
8693 total += dir_size_bytes(&p);
8694 }
8695 }
8696 }
8697 total
8698}
8699
8700#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
8702 if bytes >= 1_073_741_824 {
8703 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8704 } else if bytes >= 1_048_576 {
8705 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8706 } else if bytes >= 1_024 {
8707 format!("{:.0} KB", bytes as f64 / 1_024.0)
8708 } else {
8709 format!("{bytes} B")
8710 }
8711}
8712
8713#[allow(clippy::too_many_lines)]
8714fn build_preview_html(
8715 root: &Path,
8717 include_patterns: &[String],
8718 exclude_patterns: &[String],
8719) -> Result<String> {
8720 if !root.exists() {
8721 return Ok(format!(
8722 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8723 escape_html(&display_path(root))
8724 ));
8725 }
8726
8727 let _selected = display_path(root);
8728 let mut stats = PreviewStats::default();
8729 let mut rows = Vec::new();
8730 let mut languages = Vec::new();
8731 let mut budget = PreviewBudget {
8732 shown: 0,
8733 max_entries: 600,
8734 max_depth: 9,
8735 };
8736 let mut next_row_id = 1usize;
8737
8738 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8739 || root.to_string_lossy().into_owned(),
8740 std::string::ToString::to_string,
8741 );
8742 let root_modified = root
8743 .metadata()
8744 .ok()
8745 .and_then(|meta| meta.modified().ok())
8746 .map_or_else(|| "-".to_string(), format_system_time);
8747
8748 rows.push(PreviewRow {
8749 row_id: 0,
8750 parent_row_id: None,
8751 depth: 0,
8752 name: format!("{root_name}/"),
8753 kind: PreviewKind::Dir,
8754 is_dir: true,
8755 language: None,
8756 modified: root_modified,
8757 type_label: "Directory".to_string(),
8758 });
8759 collect_preview_rows(
8760 root,
8761 root,
8762 0,
8763 Some(0),
8764 &mut next_row_id,
8765 &mut budget,
8766 &mut stats,
8767 &mut rows,
8768 &mut languages,
8769 include_patterns,
8770 exclude_patterns,
8771 )?;
8772
8773 let root_size = format_dir_size(dir_size_bytes(root));
8774
8775 let mut out = String::new();
8776 write!(
8777 out,
8778 r#"<div class="explorer-wrap" data-project-size="{}">"#,
8779 escape_html(&root_size)
8780 )
8781 .ok();
8782 out.push_str(r#"<div class="explorer-toolbar compact">"#);
8783 out.push_str(r#"<div class="explorer-title-group">"#);
8784 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8785 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8786 out.push_str(r"</div></div>");
8787
8788 out.push_str(r#"<div class="scope-stats">"#);
8789 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();
8790 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();
8791 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();
8792 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();
8793 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();
8794 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>"#);
8795 out.push_str(r"</div>");
8796
8797 let submodules = sloc_core::detect_submodules(root);
8798 if !submodules.is_empty() {
8799 let count = submodules.len();
8800 out.push_str(r#"<div class="submodule-preview-strip">"#);
8801 write!(
8802 out,
8803 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>{}</strong> git submodule{} detected</div>"#,
8804 count,
8805 if count == 1 { "" } else { "s" }
8806 )
8807 .ok();
8808 out.push_str(r#"<div class="submodule-preview-chips">"#);
8809 for (sub_name, sub_rel_path) in &submodules {
8810 let sub_abs = root.join(sub_rel_path);
8811 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8812 let mut sub_stats = PreviewStats::default();
8813 let mut sub_rows: Vec<PreviewRow> = Vec::new();
8814 let mut sub_langs: Vec<&'static str> = Vec::new();
8815 let mut sub_budget = PreviewBudget {
8816 shown: 0,
8817 max_entries: 2000,
8818 max_depth: 9,
8819 };
8820 let mut sub_next_id = 1usize;
8821 let _ = collect_preview_rows(
8822 &sub_abs,
8823 &sub_abs,
8824 0,
8825 None,
8826 &mut sub_next_id,
8827 &mut sub_budget,
8828 &mut sub_stats,
8829 &mut sub_rows,
8830 &mut sub_langs,
8831 &[],
8832 &[],
8833 );
8834 let stats_json = format!(
8835 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
8836 sub_stats.directories,
8837 sub_stats.files,
8838 sub_stats.supported,
8839 sub_stats.skipped,
8840 sub_stats.unsupported
8841 );
8842 write!(
8843 out,
8844 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>"#,
8845 escape_html(sub_name),
8846 escape_html(&sub_rel_path.to_string_lossy()),
8847 escape_html(&sub_size),
8848 escape_html(&stats_json),
8849 escape_html(sub_name),
8850 escape_html(&sub_size),
8851 )
8852 .ok();
8853 }
8854 out.push_str(r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#);
8855 out.push_str(r"</div>");
8856 }
8857
8858 out.push_str(r#"<div class="scope-info-row">"#);
8859 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
8860 if languages.is_empty() {
8861 out.push_str(
8862 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
8863 );
8864 } else {
8865 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
8866 for language in &languages {
8867 if let Some(icon) = language_icon_file(language) {
8868 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();
8869 } else if let Some(svg) = language_inline_svg(language) {
8870 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();
8871 } else {
8872 write!(
8873 out,
8874 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
8875 escape_html(&language.to_ascii_lowercase()),
8876 escape_html(language)
8877 )
8878 .ok();
8879 }
8880 }
8881 }
8882 out.push_str(r"</div></div>");
8883 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>"#);
8884 out.push_str(r"</div>");
8885
8886 out.push_str(r#"<div class="file-explorer-shell">"#);
8887 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>"#);
8888 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>"#);
8889 out.push_str(r#"<div class="file-explorer-tree">"#);
8890 for row in rows {
8891 let status_label = row.kind.label();
8892 let lang_attr = row.language.unwrap_or("");
8893 let toggle_html = if row.is_dir {
8894 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
8895 .to_string()
8896 } else {
8897 r#"<span class="tree-bullet">•</span>"#.to_string()
8898 };
8899 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();
8900 }
8901 if budget.shown >= budget.max_entries {
8902 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>"#);
8903 }
8904 out.push_str(r"</div></div></div>");
8905
8906 Ok(out)
8907}
8908
8909#[derive(Default)]
8910struct PreviewStats {
8911 directories: usize,
8912 files: usize,
8913 supported: usize,
8914 skipped: usize,
8915 unsupported: usize,
8916}
8917
8918struct PreviewRow {
8919 row_id: usize,
8920 parent_row_id: Option<usize>,
8921 depth: usize,
8922 name: String,
8923 kind: PreviewKind,
8924 is_dir: bool,
8925 language: Option<&'static str>,
8926 modified: String,
8927 type_label: String,
8928}
8929
8930#[derive(Copy, Clone)]
8931enum PreviewKind {
8932 Dir,
8933 Supported,
8934 Skipped,
8935 Unsupported,
8936}
8937
8938impl PreviewKind {
8939 const fn filter_key(self) -> &'static str {
8940 match self {
8941 Self::Dir => "dir",
8942 Self::Supported => "supported",
8943 Self::Skipped => "skipped",
8944 Self::Unsupported => "unsupported",
8945 }
8946 }
8947
8948 const fn label(self) -> &'static str {
8949 match self {
8950 Self::Dir => "dir",
8951 Self::Supported => "supported",
8952 Self::Skipped => "skipped by policy",
8953 Self::Unsupported => "unsupported",
8954 }
8955 }
8956
8957 const fn badge_class(self) -> &'static str {
8958 match self {
8959 Self::Dir => "badge badge-dir",
8960 Self::Supported => "badge badge-scan",
8961 Self::Skipped => "badge badge-skip",
8962 Self::Unsupported => "badge badge-unsupported",
8963 }
8964 }
8965
8966 const fn node_class(self) -> &'static str {
8967 match self {
8968 Self::Dir => "tree-node-dir",
8969 Self::Supported => "tree-node-supported",
8970 Self::Skipped => "tree-node-skipped",
8971 Self::Unsupported => "tree-node-unsupported",
8972 }
8973 }
8974}
8975
8976struct PreviewBudget {
8977 shown: usize,
8978 max_entries: usize,
8979 max_depth: usize,
8980}
8981
8982#[allow(clippy::too_many_arguments)]
8985fn handle_preview_dir_entry(
8986 root: &Path,
8987 path: &Path,
8988 name: &str,
8989 modified: String,
8990 depth: usize,
8991 parent_row_id: Option<usize>,
8992 row_id: usize,
8993 next_row_id: &mut usize,
8994 budget: &mut PreviewBudget,
8995 stats: &mut PreviewStats,
8996 rows: &mut Vec<PreviewRow>,
8997 languages: &mut Vec<&'static str>,
8998 include_patterns: &[String],
8999 exclude_patterns: &[String],
9000) -> Result<()> {
9001 let relative = preview_relative_path(root, path);
9002 if should_skip_preview_directory(&relative, exclude_patterns) {
9003 return Ok(());
9004 }
9005 stats.directories += 1;
9006 rows.push(PreviewRow {
9007 row_id,
9008 parent_row_id,
9009 depth: depth + 1,
9010 name: format!("{name}/"),
9011 kind: PreviewKind::Dir,
9012 is_dir: true,
9013 language: None,
9014 modified,
9015 type_label: "Directory".to_string(),
9016 });
9017 budget.shown += 1;
9018 if !matches!(name, ".git" | "node_modules" | "target") {
9019 collect_preview_rows(
9020 root,
9021 path,
9022 depth + 1,
9023 Some(row_id),
9024 next_row_id,
9025 budget,
9026 stats,
9027 rows,
9028 languages,
9029 include_patterns,
9030 exclude_patterns,
9031 )?;
9032 }
9033 Ok(())
9034}
9035
9036#[allow(clippy::too_many_arguments)]
9038fn handle_preview_file_entry(
9039 root: &Path,
9040 path: &Path,
9041 name: &str,
9042 modified: String,
9043 depth: usize,
9044 parent_row_id: Option<usize>,
9045 row_id: usize,
9046 budget: &mut PreviewBudget,
9047 stats: &mut PreviewStats,
9048 rows: &mut Vec<PreviewRow>,
9049 languages: &mut Vec<&'static str>,
9050 include_patterns: &[String],
9051 exclude_patterns: &[String],
9052) {
9053 let relative = preview_relative_path(root, path);
9054 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
9055 return;
9056 }
9057 stats.files += 1;
9058 let kind = classify_preview_file(name);
9059 match kind {
9060 PreviewKind::Supported => stats.supported += 1,
9061 PreviewKind::Skipped => stats.skipped += 1,
9062 PreviewKind::Unsupported => stats.unsupported += 1,
9063 PreviewKind::Dir => {}
9064 }
9065 let language = detect_language_name(name);
9066 if let Some(lang) = language {
9067 if !languages.contains(&lang) {
9068 languages.push(lang);
9069 }
9070 }
9071 rows.push(PreviewRow {
9072 row_id,
9073 parent_row_id,
9074 depth: depth + 1,
9075 name: name.to_owned(),
9076 kind,
9077 is_dir: false,
9078 language,
9079 modified,
9080 type_label: preview_type_label(name, language, kind),
9081 });
9082 budget.shown += 1;
9083}
9084
9085#[allow(clippy::too_many_arguments)]
9086#[allow(clippy::too_many_lines)]
9087fn collect_preview_rows(
9088 root: &Path,
9090 dir: &Path,
9091 depth: usize,
9092 parent_row_id: Option<usize>,
9093 next_row_id: &mut usize,
9094 budget: &mut PreviewBudget,
9095 stats: &mut PreviewStats,
9096 rows: &mut Vec<PreviewRow>,
9097 languages: &mut Vec<&'static str>,
9098 include_patterns: &[String],
9099 exclude_patterns: &[String],
9100) -> Result<()> {
9101 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9102 return Ok(());
9103 }
9104
9105 let mut entries = fs::read_dir(dir)
9106 .with_context(|| format!("failed to read directory {}", dir.display()))?
9107 .filter_map(std::result::Result::ok)
9108 .collect::<Vec<_>>();
9109 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9110
9111 for entry in entries {
9112 if budget.shown >= budget.max_entries {
9113 break;
9114 }
9115
9116 let path = entry.path();
9117 let name = entry.file_name().to_string_lossy().into_owned();
9118 let Ok(metadata) = entry.metadata() else {
9119 continue;
9120 };
9121 let row_id = *next_row_id;
9122 *next_row_id += 1;
9123 let modified = metadata
9124 .modified()
9125 .ok()
9126 .map_or_else(|| "-".to_string(), format_system_time);
9127
9128 if metadata.is_dir() {
9129 handle_preview_dir_entry(
9130 root,
9131 &path,
9132 &name,
9133 modified,
9134 depth,
9135 parent_row_id,
9136 row_id,
9137 next_row_id,
9138 budget,
9139 stats,
9140 rows,
9141 languages,
9142 include_patterns,
9143 exclude_patterns,
9144 )?;
9145 continue;
9146 }
9147
9148 if metadata.is_file() {
9149 handle_preview_file_entry(
9150 root,
9151 &path,
9152 &name,
9153 modified,
9154 depth,
9155 parent_row_id,
9156 row_id,
9157 budget,
9158 stats,
9159 rows,
9160 languages,
9161 include_patterns,
9162 exclude_patterns,
9163 );
9164 }
9165 }
9166
9167 Ok(())
9168}
9169
9170fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9171 if let Some(language) = language {
9172 return format!("{language} source");
9173 }
9174 let lower = name.to_ascii_lowercase();
9175 let ext = Path::new(&lower)
9176 .extension()
9177 .and_then(|e| e.to_str())
9178 .unwrap_or("");
9179 match kind {
9180 PreviewKind::Skipped => {
9181 if lower.ends_with(".min.js") {
9182 "Minified asset".to_string()
9183 } else if [
9184 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9185 ]
9186 .contains(&ext)
9187 {
9188 "Binary or archive".to_string()
9189 } else {
9190 "Skipped file".to_string()
9191 }
9192 }
9193 PreviewKind::Unsupported => {
9194 if ext.is_empty() {
9195 "Unsupported file".to_string()
9196 } else {
9197 format!("{} file", ext.to_ascii_uppercase())
9198 }
9199 }
9200 PreviewKind::Supported => "Supported source".to_string(),
9201 PreviewKind::Dir => "Directory".to_string(),
9202 }
9203}
9204
9205fn format_system_time(time: SystemTime) -> String {
9206 #[allow(clippy::cast_possible_wrap)]
9207 let secs = match time.duration_since(UNIX_EPOCH) {
9208 Ok(duration) => duration.as_secs() as i64,
9209 Err(_) => return "-".to_string(),
9210 };
9211 let days = secs.div_euclid(86_400);
9212 let secs_of_day = secs.rem_euclid(86_400);
9213 let (year, month, day) = civil_from_days(days);
9214 let hour = secs_of_day / 3_600;
9215 let minute = (secs_of_day % 3_600) / 60;
9216 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9217}
9218
9219#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9220fn civil_from_days(days: i64) -> (i32, u32, u32) {
9221 let z = days + 719_468;
9222 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9223 let doe = z - era * 146_097;
9224 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9225 let y = yoe + era * 400;
9226 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9227 let mp = (5 * doy + 2) / 153;
9228 let d = doy - (153 * mp + 2) / 5 + 1;
9229 let m = mp + if mp < 10 { 3 } else { -9 };
9230 let year = y + i64::from(m <= 2);
9231 (year as i32, m as u32, d as u32)
9232}
9233
9234#[allow(clippy::case_sensitive_file_extension_comparisons)]
9237fn detect_language_name(name: &str) -> Option<&'static str> {
9238 let lower = name.to_ascii_lowercase();
9239 if lower.ends_with(".c") || lower.ends_with(".h") {
9240 Some("C")
9241 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9242 .iter()
9243 .any(|s| lower.ends_with(s))
9244 {
9245 Some("C++")
9246 } else if lower.ends_with(".cs") {
9247 Some("C#")
9248 } else if lower.ends_with(".py") {
9249 Some("Python")
9250 } else if lower.ends_with(".sh") {
9251 Some("Shell")
9252 } else if [".ps1", ".psm1", ".psd1"]
9253 .iter()
9254 .any(|s| lower.ends_with(s))
9255 {
9256 Some("PowerShell")
9257 } else {
9258 None
9259 }
9260}
9261
9262fn language_icon_file(language: &str) -> Option<&'static str> {
9263 match language {
9264 "C" => Some("c.png"),
9265 "C++" => Some("cpp.png"),
9266 "C#" => Some("c-sharp.png"),
9267 "Python" => Some("python.png"),
9268 "Shell" => Some("shell.png"),
9269 "PowerShell" => Some("powershell.png"),
9270 "JavaScript" => Some("java-script.png"),
9271 "HTML" => Some("html-5.png"),
9272 "Java" => Some("java.png"),
9273 "Visual Basic" => Some("visual-basic.png"),
9274 "Assembly" => Some("asm.png"),
9275 "Go" => Some("go.png"),
9276 "R" => Some("r.png"),
9277 "XML" => Some("xml.png"),
9278 "Groovy" => Some("groovy.png"),
9279 "Dockerfile" => Some("docker.png"),
9280 "Makefile" => Some("makefile.svg"),
9281 "Perl" => Some("perl.svg"),
9282 _ => None,
9283 }
9284}
9285
9286fn language_inline_svg(language: &str) -> Option<&'static str> {
9291 match language {
9292 "Rust" => Some(
9293 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>"##,
9294 ),
9295 "TypeScript" => Some(
9296 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>"##,
9297 ),
9298 _ => None,
9299 }
9300}
9301
9302#[allow(clippy::case_sensitive_file_extension_comparisons)]
9305fn classify_preview_file(name: &str) -> PreviewKind {
9306 let lower = name.to_ascii_lowercase();
9307
9308 let scannable = [
9309 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9310 ".psm1", ".psd1",
9311 ]
9312 .iter()
9313 .any(|suffix| lower.ends_with(suffix));
9314
9315 if scannable {
9316 PreviewKind::Supported
9317 } else if lower.ends_with(".min.js")
9318 || lower.ends_with(".lock")
9319 || lower.ends_with(".png")
9320 || lower.ends_with(".jpg")
9321 || lower.ends_with(".jpeg")
9322 || lower.ends_with(".gif")
9323 || lower.ends_with(".zip")
9324 || lower.ends_with(".pdf")
9325 || lower.ends_with(".pyc")
9326 || lower.ends_with(".xz")
9327 || lower.ends_with(".tar")
9328 || lower.ends_with(".gz")
9329 {
9330 PreviewKind::Skipped
9331 } else {
9332 PreviewKind::Unsupported
9333 }
9334}
9335
9336fn preview_relative_path(root: &Path, path: &Path) -> String {
9337 path.strip_prefix(root)
9338 .ok()
9339 .unwrap_or(path)
9340 .to_string_lossy()
9341 .replace('\\', "/")
9342 .trim_matches('/')
9343 .to_string()
9344}
9345
9346fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9347 if relative.is_empty() {
9348 return false;
9349 }
9350
9351 exclude_patterns.iter().any(|pattern| {
9352 wildcard_match(pattern, relative)
9353 || wildcard_match(pattern, &format!("{relative}/"))
9354 || wildcard_match(pattern, &format!("{relative}/placeholder"))
9355 })
9356}
9357
9358fn should_include_preview_file(
9359 relative: &str,
9360 include_patterns: &[String],
9361 exclude_patterns: &[String],
9362) -> bool {
9363 if relative.is_empty() {
9364 return true;
9365 }
9366
9367 let included = include_patterns.is_empty()
9368 || include_patterns
9369 .iter()
9370 .any(|pattern| wildcard_match(pattern, relative));
9371 let excluded = exclude_patterns
9372 .iter()
9373 .any(|pattern| wildcard_match(pattern, relative));
9374
9375 included && !excluded
9376}
9377
9378fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9379 let pattern = pattern.trim().replace('\\', "/");
9380 let candidate = candidate.trim().replace('\\', "/");
9381 let p = pattern.as_bytes();
9382 let c = candidate.as_bytes();
9383 let mut pi = 0usize;
9384 let mut ci = 0usize;
9385 let mut star: Option<usize> = None;
9386 let mut star_match = 0usize;
9387
9388 while ci < c.len() {
9389 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9390 pi += 1;
9391 ci += 1;
9392 } else if pi < p.len() && p[pi] == b'*' {
9393 while pi < p.len() && p[pi] == b'*' {
9394 pi += 1;
9395 }
9396 star = Some(pi);
9397 star_match = ci;
9398 } else if let Some(star_pi) = star {
9399 star_match += 1;
9400 ci = star_match;
9401 pi = star_pi;
9402 } else {
9403 return false;
9404 }
9405 }
9406
9407 while pi < p.len() && p[pi] == b'*' {
9408 pi += 1;
9409 }
9410
9411 pi == p.len()
9412}
9413
9414fn escape_html(value: &str) -> String {
9415 value
9416 .replace('&', "&")
9417 .replace('<', "<")
9418 .replace('>', ">")
9419 .replace('"', """)
9420 .replace('\'', "'")
9421}
9422
9423#[derive(Clone)]
9424struct SubmoduleRow {
9425 name: String,
9426 relative_path: String,
9427 files_analyzed: u64,
9428 code_lines: u64,
9429 comment_lines: u64,
9430 blank_lines: u64,
9431 total_physical_lines: u64,
9432 html_url: Option<String>,
9433}
9434
9435#[derive(Template)]
9436#[template(
9437 source = r##"
9438<!doctype html>
9439<html lang="en">
9440<head>
9441 <meta charset="utf-8">
9442 <title>OxideSLOC | tmp-sloc</title>
9443 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9444 <style nonce="{{ csp_nonce }}">
9445 :root {
9446 --bg: #efe9e2;
9447 --surface: #fcfaf7;
9448 --surface-2: #f7f0e8;
9449 --surface-3: #efe3d5;
9450 --line: #dfcfbf;
9451 --line-strong: #cfb29c;
9452 --text: #2f241c;
9453 --muted: #6f6257;
9454 --muted-2: #917f71;
9455 --nav: #b85d33;
9456 --nav-2: #7a371b;
9457 --accent: #2563eb;
9458 --accent-2: #1d4ed8;
9459 --oxide: #b85d33;
9460 --oxide-2: #8f4220;
9461 --success-bg: #eaf9ee;
9462 --success-text: #1c8746;
9463 --warn-bg: #fff2d8;
9464 --warn-text: #926000;
9465 --danger-bg: #fdeaea;
9466 --danger-text: #b33b3b;
9467 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9468 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9469 --radius: 14px;
9470 }
9471
9472 body.dark-theme {
9473 --bg: #1b1511;
9474 --surface: #261c17;
9475 --surface-2: #2d221d;
9476 --surface-3: #372922;
9477 --line: #524238;
9478 --line-strong: #6c5649;
9479 --text: #f5ece6;
9480 --muted: #c7b7aa;
9481 --muted-2: #aa9485;
9482 --nav: #b85d33;
9483 --nav-2: #7a371b;
9484 --accent: #6f9bff;
9485 --accent-2: #4a78ee;
9486 --oxide: #d37a4c;
9487 --oxide-2: #b35428;
9488 --success-bg: #163927;
9489 --success-text: #8fe2a8;
9490 --warn-bg: #3c2d11;
9491 --warn-text: #f3cb75;
9492 --danger-bg: #3d1f1f;
9493 --danger-text: #ff9f9f;
9494 --shadow: 0 14px 28px rgba(0,0,0,0.28);
9495 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9496 }
9497
9498 * { box-sizing: border-box; }
9499 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); }
9500 html { overflow-y: scroll; }
9501 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9502 .top-nav, .page, .loading { position: relative; z-index: 2; }
9503 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9504 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9505 .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); }
9506 .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; }
9507 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9508 .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)); }
9509 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9510 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9511 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9512 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9513 .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; }
9514 .nav-project-pill.visible { display:inline-flex; }
9515 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9516 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9517 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9518 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9519 @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; } }
9520 .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; }
9521 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9522 .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; }
9523 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9524 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9525 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9526 .theme-toggle .icon-sun { display:none; }
9527 body.dark-theme .theme-toggle .icon-sun { display:block; }
9528 body.dark-theme .theme-toggle .icon-moon { display:none; }
9529 .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;}
9530 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9531 .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);}
9532 .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;}
9533 .settings-close:hover{color:var(--text);background:var(--surface-2);}
9534 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9535 .settings-modal-body{padding:14px 16px 16px;}
9536 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9537 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9538 .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;}
9539 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9540 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9541 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9542 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9543 .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;}
9544 .tz-select:focus{border-color:var(--oxide);}
9545 .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; }
9546 .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;}
9547 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9548 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9549 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9550 .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; }
9551 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9552 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9553 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9554 .wb-stats-header { padding: 10px 24px 0; }
9555 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9556 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9557 .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; }
9558 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9559 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9560 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9561 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9562 .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; }
9563 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9564 .ws-stat-analyzers { position: relative; }
9565 .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; }
9566 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9567 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9568 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9569 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9570 .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; }
9571 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9572 .ws-divider { display: none; }
9573 .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%; }
9574 .ws-path-link:hover { color:var(--oxide); }
9575 body.dark-theme .ws-path-link { color:var(--oxide); }
9576 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9577 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9578 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9579 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9580 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9581 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9582 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9583 .ws-mini-box-lg { flex:2 1 0; }
9584 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9585 .ws-mini-box-br { flex:1.5 1 0; }
9586 .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); }
9587 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9588 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9589 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9590 .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; }
9591 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9592 .git-source-banner strong { font-weight:800; color:var(--text); }
9593 .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; }
9594 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9595 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9596 .git-source-banner a:hover { text-decoration:underline; }
9597 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9598 .path-scope-sep { background:var(--line); margin:4px 14px; }
9599 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9600 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9601 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9602 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9603 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9604 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9605 .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; }
9606 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9607 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9608 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9609 .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; }
9610 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9611 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9612 [data-wb-tip] { cursor:help; }
9613 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9614 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9615 .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; }
9616 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9617 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9618 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9619 .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; }
9620 .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); }
9621 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9622 .side-info-card { padding: 18px; }
9623 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9624 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9625 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9626 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9627 .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); }
9628 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9629 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9630 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9631 .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; }
9632 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9633 .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; }
9634 .side-stack::-webkit-scrollbar { display: none; }
9635 .step-nav { padding: 20px 16px; }
9636 .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); }
9637 .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; }
9638 .step-button:hover { background: var(--surface-2); }
9639 .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); }
9640 .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; }
9641 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9642 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9643 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9644 .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); }
9645 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9646 .step-nav-sum-row:last-child { border-bottom:none; }
9647 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9648 .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; }
9649 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9650 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9651 .quick-scan-section { padding: 10px 4px 14px; }
9652 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9653 .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; }
9654 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9655 .quick-scan-btn:active { transform:translateY(0); }
9656 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9657 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9658 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9659 @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);} }
9660 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9661 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9662 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9663 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9664 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9665 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9666 .step-button.done .step-check { opacity:1; }
9667 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9668 .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; }
9669 .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; }
9670 .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; }
9671 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9672 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9673 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9674 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9675 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9676 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9677 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9678 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9679 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9680 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9681 .card-body { padding: 22px; }
9682 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9683 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9684 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9685 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9686 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9687 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9688 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9689 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9690 .field { min-width:0; }
9691 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9692 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; }
9693 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); }
9694 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9695 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); }
9696 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9697 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9698 .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; }
9699 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9700 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9701 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9702 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9703 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9704 .input-group.compact { grid-template-columns: 1fr auto auto; }
9705 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9706 .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)); }
9707 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9708 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9709 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9710 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9711 .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; }
9712 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9713 .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; }
9714 .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); }
9715 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9716 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9717 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9718 button.secondary { background: var(--surface); }
9719 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9720 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9721 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9722 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9723 .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); }
9724 .section + .wizard-actions { border-top: none; padding-top: 0; }
9725 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9726 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9727 .field-help-grid.coupled-help { margin-top: 12px; }
9728 .field-help-grid.preset-grid { align-items: start; }
9729 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9730 .preset-inline-row .field { margin: 0; }
9731 .preset-inline-row .explainer-card { margin: 0; }
9732 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9733 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9734 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9735 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9736 .preset-kv-row > :last-child { flex:1; min-width:0; }
9737 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9738 .output-field-row .field { margin: 0; }
9739 .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; }
9740 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9741 .step3-subtitle { margin-bottom: 10px; max-width: none; }
9742 .counting-intro { margin-bottom: 8px; max-width: none; }
9743 .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; }
9744 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9745 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9746 .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; }
9747 .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; }
9748 .section-spacer-top { margin-top: 28px; }
9749 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9750 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9751 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9752 .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); }
9753 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9754 .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; }
9755 .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; }
9756 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9757 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9758 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9759 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9760 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9761 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9762 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9763 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9764 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9765 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9766 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9767 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9768 .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); }
9769 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9770 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9771 .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; }
9772 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9773 .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; }
9774 .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; }
9775 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9776 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9777 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9778 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9779 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9780 .advanced-rule-description strong { color: var(--text); }
9781 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9782 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9783 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9784 .review-link:hover { text-decoration: underline; }
9785 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9786 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9787 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9788 .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; }
9789 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9790 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9791 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9792 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9793 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9794 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9795 .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; }
9796 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9797 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9798 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9799 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9800 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9801 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9802 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9803 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9804 .review-card ul { padding-left: 18px; margin: 0; }
9805 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9806 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9807 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9808 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9809 .review-card { min-height: 200px; }
9810 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9811 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9812 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9813 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9814 .lang-overflow-chip { position:relative; cursor:default; }
9815 .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; }
9816 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9817 .git-inline-row { align-items:start; }
9818 .mixed-line-card { display:flex; flex-direction:column; }
9819 .preset-inline-row .toggle-card { justify-content: center; }
9820 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9821 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9822 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9823 .explorer-title { font-size: 18px; font-weight: 850; }
9824 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9825 .explorer-subtitle.wide { max-width: none; }
9826 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9827 .better-spacing { align-items:flex-start; justify-content:flex-end; }
9828 .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; }
9829 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9830 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9831 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9832 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9833 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9834 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9835 .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; }
9836 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
9837 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
9838 .scope-stat-button.supported { background: var(--success-bg); }
9839 .scope-stat-button.skipped { background: var(--warn-bg); }
9840 .scope-stat-button.unsupported { background: var(--danger-bg); }
9841 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
9842 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
9843 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
9844 [data-tooltip] { position: relative; }
9845 [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); }
9846 [data-tooltip]:hover::after { display: block; }
9847 .scope-stat-button[data-tooltip] { cursor: pointer; }
9848 .badge[data-tooltip] { cursor: help; }
9849 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
9850 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
9851 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
9852 .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; }
9853 .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; }
9854 code { display:inline-block; margin-top:0; padding:2px 7px; }
9855 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9856 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
9857 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
9858 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
9859 .language-pill.muted-pill { color: var(--muted); }
9860 button.language-pill { appearance:none; cursor:pointer; }
9861 .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); }
9862 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
9863 .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; }
9864 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
9865 .file-explorer-search-row { margin-left: auto; }
9866 .explorer-filter-select { min-width: 170px; width: 170px; }
9867 .explorer-search { min-width: 300px; width: 300px; }
9868 .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); }
9869 .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; }
9870 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
9871 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
9872 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
9873 .file-explorer-tree { max-height: 640px; overflow:auto; }
9874 .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); }
9875 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
9876 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
9877 .tree-row.hidden-by-filter { display:none !important; }
9878 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
9879 .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; }
9880 .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; }
9881 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
9882 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
9883 .tree-node { display:inline-flex; align-items:center; min-width:0; }
9884 .tree-node-dir { color: var(--text); font-weight: 800; }
9885 .tree-node-supported { color: var(--success-text); }
9886 .tree-node-skipped { color: var(--warn-text); }
9887 .tree-node-unsupported { color: var(--danger-text); }
9888 .tree-node-more { color: var(--muted-2); font-style: italic; }
9889 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
9890 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
9891 .tree-status-cell { display:flex; justify-content:flex-start; }
9892 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
9893 .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; }
9894 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
9895 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
9896 .cov-scan-idle { display:none; }
9897 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
9898 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
9899 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
9900 .cov-scan-title { font-weight:600; font-size:12.5px; }
9901 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
9902 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
9903 .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; }
9904 .cov-scan-use:hover { opacity:.75; }
9905 .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; }
9906 .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; }
9907 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
9908 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
9909 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
9910 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
9911 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
9912 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
9913 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
9914 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
9915 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
9916 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
9917 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
9918 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
9919 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
9920 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
9921 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
9922 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
9923 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
9924 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
9925 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
9926 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
9927 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
9928 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
9929 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
9930 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
9931 .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); }
9932 .loading.active { display:flex; }
9933 .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; }
9934 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
9935 .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; }
9936 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
9937 .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; }
9938 .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; }
9939 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
9940 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
9941 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
9942 .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; }
9943 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
9944 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
9945 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
9946 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
9947 .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; }
9948 .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; }
9949 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
9950 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
9951 .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; }
9952 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
9953 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
9954 .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; }
9955 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
9956 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
9957 .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; }
9958 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
9959 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
9960 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
9961 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
9962 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
9963 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
9964 .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; }
9965 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
9966 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
9967 .hidden { display:none !important; }
9968 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9969 .site-footer a{color:var(--muted);}
9970 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
9971 @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; } }
9972 .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;}
9973 @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));}}
9974 .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;}
9975 .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; }
9976 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
9977 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
9978 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
9979 .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; }
9980 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
9981 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
9982 .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; }
9983 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
9984 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
9985 .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; }
9986 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
9987 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
9988 .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; }
9989 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
9990 .info-icon-btn:hover { color:var(--text); }
9991 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); }
9992 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
9993 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
9994 </style>
9995</head>
9996<body>
9997 <div class="background-watermarks" aria-hidden="true">
9998 <img src="/images/logo/logo-text.png" alt="" />
9999 <img src="/images/logo/logo-text.png" alt="" />
10000 <img src="/images/logo/logo-text.png" alt="" />
10001 <img src="/images/logo/logo-text.png" alt="" />
10002 <img src="/images/logo/logo-text.png" alt="" />
10003 <img src="/images/logo/logo-text.png" alt="" />
10004 <img src="/images/logo/logo-text.png" alt="" />
10005 <img src="/images/logo/logo-text.png" alt="" />
10006 <img src="/images/logo/logo-text.png" alt="" />
10007 <img src="/images/logo/logo-text.png" alt="" />
10008 <img src="/images/logo/logo-text.png" alt="" />
10009 <img src="/images/logo/logo-text.png" alt="" />
10010 <img src="/images/logo/logo-text.png" alt="" />
10011 <img src="/images/logo/logo-text.png" alt="" />
10012 </div>
10013 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10014 <div class="top-nav">
10015 <div class="top-nav-inner">
10016 <a class="brand" href="/">
10017 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
10018 <div class="brand-copy">
10019 <div class="brand-title">OxideSLOC</div>
10020 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
10021 </div>
10022 </a>
10023 <div class="nav-project-slot">
10024 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
10025 <span class="nav-project-label">Project</span>
10026 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
10027 </div>
10028 </div>
10029 <div class="nav-status">
10030 <a class="nav-pill" href="/">Home</a>
10031 <div class="nav-dropdown">
10032 <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>
10033 <div class="nav-dropdown-menu">
10034 <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>
10035 </div>
10036 </div>
10037 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10038 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10039 <div class="nav-dropdown">
10040 <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>
10041 <div class="nav-dropdown-menu">
10042 <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>
10043 </div>
10044 </div>
10045 <div class="server-status-wrap">
10046 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
10047 <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>
10048 </div>
10049 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10050 <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>
10051 </button>
10052 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
10053 <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>
10054 <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>
10055 </button>
10056 </div>
10057 </div>
10058 </div>
10059
10060 <div class="loading" id="loading">
10061 <div class="loading-card">
10062 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10063 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10064 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10065 <div class="lc-path" id="lc-path"></div>
10066 <div class="lc-metrics" id="lc-metrics">
10067 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10068 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10069 </div>
10070 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10071 <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>
10072 <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>
10073 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10074 <div class="lc-actions hidden" id="lc-actions">
10075 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10076 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10077 </div>
10078 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10079 <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>
10080 Cancel scan
10081 </button>
10082 </div>
10083 </div>
10084
10085 <div class="page">
10086 <div class="workbench-strip">
10087 <div class="workbench-box wb-stats">
10088 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10089 <span class="wb-stats-title">Analysis session</span>
10090 </div>
10091 <div class="ws-left">
10092 <div class="ws-stat ws-stat-analyzers">
10093 <span class="ws-label">Analyzers</span>
10094 <span class="ws-value">
10095 <span class="ws-badge">41 languages</span>
10096 </span>
10097 <div class="ws-lang-tooltip">
10098 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10099 <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>
10100 <div class="ws-lang-grid">
10101 <span class="ws-lang-item">Assembly</span>
10102 <span class="ws-lang-item">C</span>
10103 <span class="ws-lang-item">C++</span>
10104 <span class="ws-lang-item">C#</span>
10105 <span class="ws-lang-item">Clojure</span>
10106 <span class="ws-lang-item">CSS</span>
10107 <span class="ws-lang-item">Dart</span>
10108 <span class="ws-lang-item">Dockerfile</span>
10109 <span class="ws-lang-item">Elixir</span>
10110 <span class="ws-lang-item">Erlang</span>
10111 <span class="ws-lang-item">F#</span>
10112 <span class="ws-lang-item">Go</span>
10113 <span class="ws-lang-item">Groovy</span>
10114 <span class="ws-lang-item">Haskell</span>
10115 <span class="ws-lang-item">HTML</span>
10116 <span class="ws-lang-item">Java</span>
10117 <span class="ws-lang-item">JavaScript</span>
10118 <span class="ws-lang-item">Julia</span>
10119 <span class="ws-lang-item">Kotlin</span>
10120 <span class="ws-lang-item">Lua</span>
10121 <span class="ws-lang-item">Makefile</span>
10122 <span class="ws-lang-item">Nim</span>
10123 <span class="ws-lang-item">Obj-C</span>
10124 <span class="ws-lang-item">OCaml</span>
10125 <span class="ws-lang-item">Perl</span>
10126 <span class="ws-lang-item">PHP</span>
10127 <span class="ws-lang-item">PowerShell</span>
10128 <span class="ws-lang-item">Python</span>
10129 <span class="ws-lang-item">R</span>
10130 <span class="ws-lang-item">Ruby</span>
10131 <span class="ws-lang-item">Rust</span>
10132 <span class="ws-lang-item">Scala</span>
10133 <span class="ws-lang-item">SCSS</span>
10134 <span class="ws-lang-item">Shell</span>
10135 <span class="ws-lang-item">SQL</span>
10136 <span class="ws-lang-item">Svelte</span>
10137 <span class="ws-lang-item">Swift</span>
10138 <span class="ws-lang-item">TypeScript</span>
10139 <span class="ws-lang-item">Vue</span>
10140 <span class="ws-lang-item">XML</span>
10141 <span class="ws-lang-item">Zig</span>
10142 </div>
10143 </div>
10144 </div>
10145 <div class="ws-divider"></div>
10146 <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>
10147 <div class="ws-divider"></div>
10148 <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>
10149 <div class="ws-divider"></div>
10150 <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.">
10151 <span class="ws-label">Output</span>
10152 <span class="ws-value">
10153 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10154 <span id="ws-output-root">project/sloc</span>
10155 </button>
10156 </span>
10157 </div>
10158 </div>
10159 </div>
10160 <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.">
10161 <div class="ws-history-label">Scan history</div>
10162 <div class="ws-history-inner">
10163 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10164 <div class="ws-mini-label">Scans</div>
10165 <div class="ws-mini-value" id="ws-scan-count">—</div>
10166 </div>
10167 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10168 <div class="ws-mini-label">Last Scan</div>
10169 <div class="ws-mini-value" id="ws-last-scan">—</div>
10170 </div>
10171 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10172 <div class="ws-mini-label">Branch</div>
10173 <div class="ws-mini-value" id="ws-branch">—</div>
10174 </div>
10175 </div>
10176 </div>
10177 </div>
10178
10179 <div class="layout">
10180 <aside class="side-stack">
10181 <section class="step-nav">
10182 <h3>Guided scan setup</h3>
10183 <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>
10184 <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>
10185 <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>
10186 <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>
10187
10188 <div class="step-steps-divider"></div>
10189
10190 <div class="step-nav-info" id="step-nav-info">
10191 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10192 <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>
10193 </div>
10194
10195 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10196 <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>
10197 <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>
10198 <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>
10199 </div>
10200
10201 <div class="quick-scan-divider"></div>
10202 <div class="quick-scan-section">
10203 <div class="quick-scan-label">No customization needed?</div>
10204 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10205 <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>
10206 Quick Scan
10207 </button>
10208 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10209 </div>
10210
10211 <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>
10212 </section>
10213
10214 </aside>
10215
10216 <section class="card">
10217 <div class="card-header">
10218 <div class="card-title-row">
10219 <div>
10220 <h1 class="card-title">Guided scan configuration</h1>
10221 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10222 </div>
10223 <div class="wizard-progress" aria-label="Scan setup progress">
10224 <div class="wizard-progress-top">
10225 <span class="wizard-progress-label">Setup progress</span>
10226 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10227 </div>
10228 <div class="wizard-progress-track">
10229 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10230 </div>
10231 </div>
10232 </div>
10233 </div>
10234 <div class="card-body">
10235 <form method="post" action="/analyze" id="analyze-form">
10236 <div class="wizard-step active" data-step="1">
10237 <div class="section">
10238 <div class="section-kicker">Step 1</div>
10239 <h2>Select project and preview scope</h2>
10240 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10241 <div class="field">
10242 <label for="path">Project path</label>
10243 {% if !git_repo.is_empty() %}
10244 <div class="git-source-banner">
10245 <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>
10246 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10247 <a href="/git-browser">← Back to Git Browser</a>
10248 </div>
10249 {% endif %}
10250 <div class="path-scope-grid">
10251 {% if !git_repo.is_empty() %}
10252 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10253 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10254 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10255 {% else %}
10256 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10257 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10258 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10259 {% endif %}
10260 <div class="path-scope-sep"></div>
10261 <div class="scope-legend-row">
10262 <span class="scope-legend-label">Scope legend:</span>
10263 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10264 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10265 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10266 </div>
10267 </div>
10268 {% if git_repo.is_empty() %}
10269 <div class="path-info-row">
10270 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10271 <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>
10272 <span id="project-size-text">Project size: —</span>
10273 </button>
10274 </div>
10275 {% else %}
10276 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10277 {% endif %}
10278 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10279 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10280 </div>
10281
10282 <div class="scope-preview-divider" aria-hidden="true"></div>
10283
10284 <div id="preview-panel">
10285 <div class="preview-error">Loading preview...</div>
10286 </div>
10287 </div>
10288
10289 <div class="section" style="margin-top:14px;">
10290 <div class="preset-inline-row git-inline-row">
10291 <div class="toggle-card" style="margin:0;">
10292 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10293 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10294 <label class="checkbox">
10295 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10296 <div>
10297 <span>Detect and separate git submodules</span>
10298 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10299 </div>
10300 </label>
10301 </div>
10302 <div class="explainer-card prominent" style="margin:0;">
10303 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10304 <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>
10305 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10306 path = libs/core
10307 url = https://github.com/org/core.git
10308
10309[submodule "libs/ui"]
10310 path = libs/ui
10311 url = https://github.com/org/ui.git</div>
10312 </div>
10313 </div>
10314 </div>
10315
10316 <div class="section">
10317 <div class="field-grid">
10318 <div class="field">
10319 <label for="include_globs">Include globs</label>
10320 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
10321 <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>
10322 </div>
10323 <div class="field">
10324 <label for="exclude_globs">Exclude globs</label>
10325 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
10326 <div id="quick-exclude-chips" class="quick-excl-row">
10327 <span class="quick-excl-label">Quick add:</span>
10328 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10329 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10330 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10331 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10332 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10333 <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>
10334 </div>
10335 <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>
10336 </div>
10337 </div>
10338 <div class="glob-guidance-grid">
10339 <div class="glob-guidance-card">
10340 <strong>How to read them</strong>
10341 <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>
10342 </div>
10343 <div class="glob-guidance-card">
10344 <strong>Common include examples</strong>
10345 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10346 </div>
10347 <div class="glob-guidance-card">
10348 <strong>Common exclude examples</strong>
10349 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10350 </div>
10351 </div>
10352 </div>
10353
10354 <div class="section" style="margin-top:14px;">
10355 <div class="preset-inline-row git-inline-row">
10356 <div class="toggle-card" style="margin:0;">
10357 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10358 <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>
10359 <div class="field" style="margin:0;">
10360 <div class="input-group compact">
10361 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10362 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10363 </div>
10364 <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>
10365 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10366 </div>
10367 </div>
10368 <div class="explainer-card prominent" style="margin:0;">
10369 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10370 <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>
10371 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10372lcov --capture --directory . --output-file coverage/lcov.info
10373
10374# C / C++ — llvm-cov (LCOV)
10375llvm-profdata merge -sparse default.profraw -o default.profdata
10376llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10377
10378# C# — coverlet (Cobertura XML)
10379dotnet test --collect:"XPlat Code Coverage"
10380
10381# Python — pytest-cov (Cobertura XML)
10382pytest --cov --cov-report=xml
10383
10384# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10385./gradlew jacocoTestReport</div>
10386 </div>
10387 </div>
10388 </div>
10389
10390 <div class="wizard-actions">
10391 <div class="left"></div>
10392 <div class="right">
10393 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10394 </div>
10395 </div>
10396 </div>
10397
10398 <div class="wizard-step" data-step="2">
10399 <div class="section">
10400 <div class="section-kicker">Step 2</div>
10401 <h2>Choose counting behavior</h2>
10402 <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>
10403 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10404 <div class="subsection-bar">Primary line classification</div>
10405 <div class="preset-kv-row">
10406 <div class="toggle-card mixed-line-card" style="margin:0;">
10407 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10408 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10409 <select id="mixed_line_policy" name="mixed_line_policy">
10410 <option value="code_only">Code only</option>
10411 <option value="code_and_comment">Code and comment</option>
10412 <option value="comment_only">Comment only</option>
10413 <option value="separate_mixed_category">Separate mixed category</option>
10414 </select>
10415 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10416 </div>
10417 <div class="explainer-card prominent" style="margin:0;">
10418 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10419 <div class="explainer-body" id="mixed-policy-description"></div>
10420 <div class="code-sample" id="mixed-policy-example"></div>
10421 </div>
10422 </div>
10423 </div>
10424
10425 <div class="subsection-bar">Additional scan rules</div>
10426 <div class="scan-rules-grid">
10427 <div class="preset-inline-row">
10428 <div class="toggle-card" style="margin:0;">
10429 <div class="field-help-title">Generated files</div>
10430 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10431 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10432 </div>
10433 <div class="explainer-card prominent" style="margin:0;">
10434 <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>
10435 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10436# Files matching codegen patterns are excluded:
10437# *.generated.cs *.pb.go *.g.dart</div>
10438 </div>
10439 </div>
10440 <div class="preset-inline-row">
10441 <div class="toggle-card" style="margin:0;">
10442 <div class="field-help-title">Minified files</div>
10443 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10444 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10445 </div>
10446 <div class="explainer-card prominent" style="margin:0;">
10447 <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>
10448 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10449# Heuristic: very long lines + low whitespace ratio
10450# jquery.min.js bundle.min.css → skipped</div>
10451 </div>
10452 </div>
10453 <div class="preset-inline-row">
10454 <div class="toggle-card" style="margin:0;">
10455 <div class="field-help-title">Vendor directories</div>
10456 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10457 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10458 </div>
10459 <div class="explainer-card prominent" style="margin:0;">
10460 <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>
10461 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10462# Directories named vendor/ node_modules/ third_party/
10463# → entire subtree is excluded from totals</div>
10464 </div>
10465 </div>
10466 <div class="preset-inline-row">
10467 <div class="toggle-card" style="margin:0;">
10468 <div class="field-help-title">Lockfiles and manifests</div>
10469 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10470 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10471 </div>
10472 <div class="explainer-card prominent" style="margin:0;">
10473 <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>
10474 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
10475# Files like package-lock.json Cargo.lock yarn.lock
10476# → skipped unless this is enabled</div>
10477 </div>
10478 </div>
10479 <div class="preset-inline-row">
10480 <div class="toggle-card" style="margin:0;">
10481 <div class="field-help-title">Binary handling</div>
10482 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10483 <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>
10484 </div>
10485 <div class="explainer-card prominent" style="margin:0;">
10486 <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>
10487 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
10488# Detected via long lines + low whitespace heuristic
10489# .png .exe .so → skipped silently</div>
10490 </div>
10491 </div>
10492 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10493 <div class="toggle-card" style="margin:0;">
10494 <div class="field-help-title">Python docstrings</div>
10495 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10496 <label class="checkbox">
10497 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10498 <span>Count as comment-style lines</span>
10499 </label>
10500 </div>
10501 <div class="explainer-card prominent" style="margin:0;">
10502 <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>
10503 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10504 </div>
10505 </div>
10506 </div>
10507 <div class="always-tracked-tip">
10508 <div class="always-tracked-tip-icon">ℹ</div>
10509 <div class="always-tracked-tip-body">
10510 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
10511 <h4>Comment and blank-line basics & Lines on the boundary</h4>
10512 <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>
10513 </div>
10514 </div>
10515
10516 <div class="wizard-actions">
10517 <div class="left">
10518 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10519 </div>
10520 <div class="right">
10521 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10522 </div>
10523 </div>
10524 </div>
10525
10526 <div class="wizard-step" data-step="3">
10527 <div class="section">
10528 <div class="section-kicker">Step 3</div>
10529 <h2>Output and report identity</h2>
10530 <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>
10531 <div class="preset-kv-row">
10532 <div class="toggle-card" style="margin:0;">
10533 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10534 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10535 <select id="scan_preset">
10536 <option value="balanced">Balanced local scan</option>
10537 <option value="code_focused">Code focused</option>
10538 <option value="comment_audit">Comment audit</option>
10539 <option value="deep_review">Deep review</option>
10540 </select>
10541 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10542 </div>
10543 <div class="explainer-card">
10544 <div class="field-help-title">Selected scan preset</div>
10545 <div class="explainer-body" id="scan-preset-description"></div>
10546 <div class="preset-summary-row" id="scan-preset-summary"></div>
10547 <div class="code-sample" id="scan-preset-example"></div>
10548 <div class="preset-note" id="scan-preset-note"></div>
10549 </div>
10550 </div>
10551 <hr class="step3-separator" />
10552 <div class="preset-kv-row">
10553 <div class="toggle-card" style="margin:0;">
10554 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10555 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10556 <select id="artifact_preset">
10557 <option value="review">Review bundle</option>
10558 <option value="full">Full bundle</option>
10559 <option value="html_only">HTML only</option>
10560 <option value="machine">Machine bundle</option>
10561 </select>
10562 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10563 </div>
10564 <div class="explainer-card">
10565 <div class="field-help-title">Selected artifact preset</div>
10566 <div class="explainer-body" id="artifact-preset-description"></div>
10567 <div class="preset-summary-row" id="artifact-preset-summary"></div>
10568 <div class="code-sample" id="artifact-preset-example"></div>
10569 </div>
10570 </div>
10571 </div>
10572
10573 <div class="section section-spacer-top">
10574 <div class="output-field-row">
10575 <div class="field">
10576 <label for="output_dir">Output directory</label>
10577 <div class="input-group compact">
10578 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10579 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10580 <button type="button" class="mini-button" id="use-default-output">Use default</button>
10581 </div>
10582 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10583 </div>
10584 <div class="output-field-aside">
10585 <strong>Where reports land</strong>
10586 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.
10587 </div>
10588 </div>
10589 </div>
10590
10591 <div class="section section-spacer-top">
10592 <div class="output-field-row">
10593 <div class="field">
10594 <label for="report_title">Report title</label>
10595 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10596 <div class="hint">Appears in HTML and PDF output headers.</div>
10597 </div>
10598 <div class="output-field-aside">
10599 <strong>Shown in exported artifacts</strong>
10600 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.
10601 </div>
10602 </div>
10603 </div>
10604
10605 <div class="section section-spacer-top">
10606 <div class="output-field-row">
10607 <div class="field">
10608 <label for="report_header_footer">Report header / footer</label>
10609 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10610 <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>
10611 </div>
10612 <div class="output-field-aside">
10613 <strong>Page-level identification</strong>
10614 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.
10615 </div>
10616 </div>
10617 </div>
10618
10619 <div class="section">
10620 <div class="section-kicker">Artifacts</div>
10621 <div class="artifact-grid" style="margin-bottom:24px;">
10622 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10623 <div class="marker">✓</div>
10624 <div class="artifact-icon">H</div>
10625 <h4>HTML report</h4>
10626 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10627 <div class="artifact-tags">
10628 <span class="soft-chip">Best for visual review</span>
10629 <span class="soft-chip">Embeddable preview</span>
10630 </div>
10631 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10632 </div>
10633 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10634 <div class="marker">✓</div>
10635 <div class="artifact-icon">P</div>
10636 <h4>PDF export</h4>
10637 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10638 <div class="artifact-tags">
10639 <span class="soft-chip">Portable snapshot</span>
10640 <span class="soft-chip">Good for handoff</span>
10641 </div>
10642 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10643 </div>
10644 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10645 <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>
10646 <div class="marker">✓</div>
10647 <div class="artifact-icon" style="color:var(--muted);">J</div>
10648 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10649 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10650 <div class="artifact-tags">
10651 <span class="soft-chip">Required for compare</span>
10652 <span class="soft-chip">Auto-enabled</span>
10653 </div>
10654 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10655 </div>
10656 </div>
10657 <div style="height:48px;flex-shrink:0;display:block;"></div>
10658 <div class="hint">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
10659 </div>
10660
10661 <div class="wizard-actions">
10662 <div class="left">
10663 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10664 </div>
10665 <div class="right">
10666 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10667 </div>
10668 </div>
10669 </div>
10670
10671 <div class="wizard-step" data-step="4">
10672 <div class="section">
10673 <div class="section-kicker">Step 4</div>
10674 <h2>Review selections and run</h2>
10675 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10676 <div class="review-grid">
10677 <div class="review-card highlight">
10678 <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>
10679 <ul id="review-scan-summary"></ul>
10680 </div>
10681 <div class="review-card highlight">
10682 <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>
10683 <ul id="review-count-summary"></ul>
10684 </div>
10685 <div class="review-card">
10686 <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>
10687 <ul id="review-artifact-summary"></ul>
10688 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10689 </div>
10690 <div class="review-card">
10691 <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>
10692 <ul id="review-preview-summary"></ul>
10693 </div>
10694 </div>
10695 </div>
10696
10697 <div class="wizard-actions">
10698 <div class="left">
10699 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10700 </div>
10701 <div class="right">
10702 <button type="submit" id="submit-button" class="primary">Run analysis</button>
10703 </div>
10704 </div>
10705 </div></form>
10706 </div>
10707 </section>
10708 </div>
10709 </div>
10710
10711 <script nonce="{{ csp_nonce }}">
10712 (function () {
10713 function startScanPhase() {
10714 var phaseEl = document.getElementById("scan-phase");
10715 if (!phaseEl) return;
10716 var phases = [
10717 "Discovering files...",
10718 "Decoding file encodings...",
10719 "Detecting languages...",
10720 "Analyzing source lines...",
10721 "Applying counting policies...",
10722 "Aggregating results...",
10723 "Rendering report..."
10724 ];
10725 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10726 var i = 0;
10727 function next() {
10728 phaseEl.style.opacity = "0";
10729 setTimeout(function () {
10730 phaseEl.textContent = phases[i];
10731 phaseEl.style.opacity = "0.85";
10732 var delay = durations[i] || 1800;
10733 i++;
10734 if (i < phases.length) { setTimeout(next, delay); }
10735 }, 200);
10736 }
10737 next();
10738 }
10739
10740 var form = document.getElementById("analyze-form");
10741 var loading = document.getElementById("loading");
10742 var submitButton = document.getElementById("submit-button");
10743 var pathInput = document.getElementById("path");
10744 var GIT_MODE = !!(pathInput && pathInput.readOnly);
10745 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10746 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10747 var outputDirInput = document.getElementById("output_dir");
10748 var reportTitleInput = document.getElementById("report_title");
10749 var previewPanel = document.getElementById("preview-panel");
10750 var refreshButton = document.getElementById("refresh-preview");
10751 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10752 var useSamplePath = document.getElementById("use-sample-path");
10753 var useDefaultOutput = document.getElementById("use-default-output");
10754 var browsePath = document.getElementById("browse-path");
10755 var browseOutputDir = document.getElementById("browse-output-dir");
10756 var browseCoverage = document.getElementById("browse-coverage");
10757 var coverageInput = document.getElementById("coverage_file");
10758 var covScanStatus = document.getElementById("cov-scan-status");
10759 var coverageSuggestTimer = null;
10760 var covAutoFilled = false;
10761 var themeToggle = document.getElementById("theme-toggle");
10762 var mixedLinePolicy = document.getElementById("mixed_line_policy");
10763 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10764 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10765 var scanPreset = document.getElementById("scan_preset");
10766 var artifactPreset = document.getElementById("artifact_preset");
10767 var includeGlobsInput = document.getElementById("include_globs");
10768 var excludeGlobsInput = document.getElementById("exclude_globs");
10769
10770 // Quick-exclude chips — append pattern to exclude_globs textarea.
10771 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10772 chip.addEventListener("click", function() {
10773 var pattern = chip.getAttribute("data-pattern") || "";
10774 if (!pattern || !excludeGlobsInput) return;
10775 var current = excludeGlobsInput.value.trim();
10776 // For the "skip all" chip, replace any existing dep patterns cleanly.
10777 var patterns = pattern.split("\n");
10778 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10779 var added = false;
10780 patterns.forEach(function(p) {
10781 p = p.trim();
10782 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10783 });
10784 if (added) {
10785 excludeGlobsInput.value = lines.join("\n");
10786 excludeGlobsInput.dispatchEvent(new Event("input"));
10787 }
10788 chip.classList.add("active");
10789 });
10790 });
10791
10792 var liveReportTitle = document.getElementById("live-report-title");
10793 var navProjectPill = document.getElementById("nav-project-pill");
10794 var navProjectTitle = document.getElementById("nav-project-title");
10795 var reportTitlePreview = null;
10796 var wizardProgressFill = document.getElementById("wizard-progress-fill");
10797 var wizardProgressValue = document.getElementById("wizard-progress-value");
10798 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10799 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10800 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10801 var reportTitleTouched = false;
10802 var currentStep = 1;
10803 var previewTimer = null;
10804 var quickScanBtn = document.getElementById("quick-scan-btn");
10805
10806 function dismissAnalysisModal() {
10807 if (loading) loading.classList.remove("active");
10808 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10809 var el = document.getElementById(id);
10810 if (el) el.classList.add("hidden");
10811 });
10812 var cancelBtn = document.getElementById("lc-cancel-btn");
10813 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10814 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10815 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10816 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10817 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10818 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10819 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10820 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10821 }
10822
10823 var lcDismissBtn = document.getElementById("lc-dismiss");
10824 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10825
10826 function startAsyncAnalysis(formData) {
10827 var gitRepo = (formData.get("git_repo") || "").toString();
10828 var gitRef = (formData.get("git_ref") || "").toString();
10829 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10830 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10831
10832 var pathEl = document.getElementById("lc-path");
10833 if (pathEl) pathEl.textContent = displayPath;
10834
10835 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10836 var el = document.getElementById(id);
10837 if (el) el.classList.add("hidden");
10838 });
10839 var cancelBtn = document.getElementById("lc-cancel-btn");
10840 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
10841 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10842 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10843 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10844 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
10845 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
10846
10847 if (loading) loading.classList.add("active");
10848
10849 var startTime = Date.now();
10850 var elapsedTimer = setInterval(function() {
10851 var s = Math.floor((Date.now() - startTime) / 1000);
10852 var el = document.getElementById("lc-elapsed");
10853 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
10854 }, 1000);
10855
10856 var warnShown = false, pollRetries = 0, activeWaitId = null;
10857
10858 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
10859
10860 function lcShowCancelled() {
10861 clearInterval(elapsedTimer);
10862 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
10863 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
10864 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
10865 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
10866 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
10867 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
10868 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
10869 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
10870 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10871 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10872 }
10873
10874 var lcCancelBtn = document.getElementById("lc-cancel-btn");
10875 if (lcCancelBtn) {
10876 lcCancelBtn.onclick = function() {
10877 if (!activeWaitId) { dismissAnalysisModal(); return; }
10878 lcCancelBtn.disabled = true;
10879 lcCancelBtn.textContent = "Cancelling…";
10880 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
10881 .then(function() { lcShowCancelled(); })
10882 .catch(function() { lcShowCancelled(); });
10883 };
10884 }
10885
10886 function lcShowError(msg) {
10887 clearInterval(elapsedTimer);
10888 lcSetPhase("Failed");
10889 var msgEl = document.getElementById("lc-err-msg");
10890 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
10891 var errEl = document.getElementById("lc-err");
10892 var actEl = document.getElementById("lc-actions");
10893 if (errEl) errEl.classList.remove("hidden");
10894 if (actEl) actEl.classList.remove("hidden");
10895 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10896 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10897 }
10898
10899 function lcPoll(waitId) {
10900 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
10901 .then(function(r) {
10902 if (!r.ok) throw new Error("HTTP " + r.status);
10903 return r.json();
10904 })
10905 .then(function(data) {
10906 pollRetries = 0;
10907 if (data.state === "complete") {
10908 clearInterval(elapsedTimer);
10909 lcSetPhase("Done");
10910 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
10911 } else if (data.state === "failed") {
10912 lcShowError(data.message);
10913 } else if (data.state === "cancelled") {
10914 lcShowCancelled();
10915 } else {
10916 var s = Math.floor((Date.now() - startTime) / 1000);
10917 if (s > 90 && !warnShown) {
10918 warnShown = true;
10919 var w = document.getElementById("lc-warn");
10920 if (w) w.classList.remove("hidden");
10921 }
10922 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
10923 setTimeout(function() { lcPoll(waitId); }, 1500);
10924 }
10925 })
10926 .catch(function() {
10927 pollRetries++;
10928 if (pollRetries >= 5) {
10929 lcShowError("Lost connection to server. Reload to check status.");
10930 } else {
10931 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
10932 }
10933 });
10934 }
10935
10936 var params = new URLSearchParams(formData);
10937 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
10938 .then(function(r) {
10939 var waitId = r.headers.get("x-wait-id");
10940 if (!waitId) { window.location.href = "/scan"; return; }
10941 activeWaitId = waitId;
10942 setTimeout(function() { lcPoll(waitId); }, 1500);
10943 })
10944 .catch(function(err) {
10945 lcShowError("Could not reach server: " + (err.message || err));
10946 });
10947 }
10948
10949 if (quickScanBtn) {
10950 quickScanBtn.addEventListener("click", function () {
10951 var pathVal = pathInput ? pathInput.value.trim() : "";
10952 if (!pathVal) {
10953 alert("Please enter or browse to a project path first.");
10954 return;
10955 }
10956 quickScanBtn.disabled = true;
10957 quickScanBtn.textContent = "Scanning...";
10958 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
10959 startAsyncAnalysis(new FormData(form));
10960 });
10961 }
10962
10963 var mixedPolicyInfo = {
10964 code_only: {
10965 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.",
10966 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'
10967 },
10968 code_and_comment: {
10969 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.",
10970 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'
10971 },
10972 comment_only: {
10973 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.",
10974 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'
10975 },
10976 separate_mixed_category: {
10977 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.",
10978 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'
10979 }
10980 };
10981
10982 var scanPresetInfo = {
10983 balanced: {
10984 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.",
10985 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
10986 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
10987 note: "Best when you want a stable local overview before making deeper adjustments.",
10988 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10989 },
10990 code_focused: {
10991 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
10992 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
10993 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
10994 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
10995 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10996 },
10997 comment_audit: {
10998 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
10999 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
11000 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
11001 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
11002 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11003 },
11004 deep_review: {
11005 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
11006 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
11007 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
11008 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
11009 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
11010 }
11011 };
11012
11013 var artifactPresetInfo = {
11014 review: {
11015 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.",
11016 chips: ["HTML", "PDF"],
11017 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
11018 },
11019 full: {
11020 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.",
11021 chips: ["HTML", "PDF", "JSON"],
11022 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
11023 },
11024 html_only: {
11025 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.",
11026 chips: ["HTML only", "Fast local review"],
11027 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
11028 },
11029 machine: {
11030 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
11031 chips: ["HTML", "JSON"],
11032 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
11033 }
11034 };
11035
11036 function applyTheme(theme) {
11037 if (theme === "dark") document.body.classList.add("dark-theme");
11038 else document.body.classList.remove("dark-theme");
11039 }
11040
11041 function loadSavedTheme() {
11042 var saved = null;
11043 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
11044 applyTheme(saved === "dark" ? "dark" : "light");
11045 }
11046
11047 function updateScrollProgress() {
11048 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
11049 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
11050 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
11051 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
11052 var step = Math.min(Math.max(currentStep, 1), 4);
11053 var base = stepBase[step];
11054 var end = stepEnd[step];
11055
11056 var scrollFrac = 0;
11057 var activePanel = document.querySelector(".wizard-step.active");
11058 if (activePanel) {
11059 var scrollTop = window.scrollY || window.pageYOffset || 0;
11060 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11061 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11062 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11063 var scrolled = scrollTop + viewH - panelTop;
11064 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11065 }
11066
11067 var percent = Math.round(base + (end - base) * scrollFrac);
11068 percent = Math.min(end, Math.max(base, percent));
11069 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11070 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11071 }
11072
11073 function updateWizardProgress() {
11074 updateScrollProgress();
11075 }
11076
11077 var stepDescriptions = [
11078 "Choose a project folder, apply scope filters, and preview which files will be counted.",
11079 "Configure how mixed code-plus-comment lines and docstrings are classified.",
11080 "Pick your output formats, scan preset, and where reports are saved.",
11081 "Review all settings and launch the analysis."
11082 ];
11083
11084 function updateStepNav(step) {
11085 var infoLabel = document.getElementById("step-nav-info-label");
11086 var infoDesc = document.getElementById("step-nav-info-desc");
11087 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11088 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
11089 }
11090
11091 function updateSidebarSummary() {
11092 var sumPath = document.getElementById("sum-path");
11093 var sumPreset = document.getElementById("sum-preset");
11094 var sumOutput = document.getElementById("sum-output");
11095 var sidebarSummary = document.getElementById("sidebar-summary");
11096 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11097 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
11098 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11099 if (sumPath) sumPath.textContent = pathVal || "—";
11100 if (sumPreset) sumPreset.textContent = presetVal || "—";
11101 if (sumOutput) sumOutput.textContent = outputVal || "—";
11102 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11103 }
11104
11105 function setStep(step, pushHistory) {
11106 currentStep = step;
11107 stepPanels.forEach(function (panel) {
11108 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11109 });
11110 stepButtons.forEach(function (button) {
11111 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11112 });
11113 var layoutEl = document.querySelector(".layout");
11114 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11115 updateWizardProgress();
11116 updateStepNav(step);
11117 stepButtons.forEach(function(btn) {
11118 var t = Number(btn.getAttribute("data-step-target"));
11119 btn.classList.toggle("done", t < step);
11120 });
11121 updateSidebarSummary();
11122
11123 if (pushHistory !== false) {
11124 try {
11125 history.pushState({ wizardStep: step }, "", "#step" + step);
11126 } catch (e) {}
11127 }
11128
11129 window.scrollTo({ top: 0, behavior: "instant" });
11130 }
11131
11132 window.addEventListener("popstate", function (e) {
11133 if (e.state && e.state.wizardStep) {
11134 setStep(e.state.wizardStep, false);
11135 } else {
11136 var hashMatch = location.hash.match(/^#step([1-4])$/);
11137 if (hashMatch) setStep(Number(hashMatch[1]), false);
11138 }
11139 });
11140
11141 function inferTitleFromPath(value) {
11142 if (!value) return "project";
11143 var cleaned = value.replace(/[\/\\]+$/, "");
11144 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11145 return parts.length ? parts[parts.length - 1] : value;
11146 }
11147
11148 function updateReportTitleFromPath() {
11149 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11150 if (!reportTitleTouched) {
11151 reportTitleInput.value = inferred;
11152 }
11153 var title = reportTitleInput.value || inferred;
11154 if (liveReportTitle) liveReportTitle.textContent = title;
11155 if (reportTitlePreview) reportTitlePreview.textContent = title;
11156 document.title = "OxideSLOC | " + title;
11157
11158 var projectPath = (pathInput.value || "").trim();
11159 if (navProjectPill && navProjectTitle) {
11160 if (projectPath.length > 0) {
11161 navProjectTitle.textContent = inferred;
11162 navProjectPill.classList.add("visible");
11163 } else {
11164 navProjectTitle.textContent = "";
11165 navProjectPill.classList.remove("visible");
11166 }
11167 }
11168 }
11169
11170 function updateMixedPolicyUI() {
11171 var key = mixedLinePolicy.value || "code_only";
11172 var info = mixedPolicyInfo[key];
11173 document.getElementById("mixed-policy-description").textContent = info.description;
11174 document.getElementById("mixed-policy-example").textContent = info.example;
11175 }
11176
11177 function updatePythonDocstringUI() {
11178 var checked = !!pythonDocstrings.checked;
11179 document.getElementById("python-docstring-example").textContent = checked
11180 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
11181 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
11182 document.getElementById("python-docstring-live-help").textContent = checked
11183 ? "Enabled: docstrings contribute to comment-style totals."
11184 : "Disabled: docstrings are not counted as comment content.";
11185 }
11186
11187 function renderPresetChips(targetId, chips) {
11188 var target = document.getElementById(targetId);
11189 if (!target) return;
11190 target.innerHTML = (chips || []).map(function (chip) {
11191 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11192 }).join('');
11193 }
11194
11195 function updatePresetDescriptions() {
11196 var scanInfo = scanPresetInfo[scanPreset.value];
11197 var artifactInfo = artifactPresetInfo[artifactPreset.value];
11198 document.getElementById("scan-preset-description").textContent = scanInfo.description;
11199 document.getElementById("scan-preset-example").textContent = scanInfo.example;
11200 document.getElementById("scan-preset-note").textContent = scanInfo.note;
11201 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11202 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11203 renderPresetChips("scan-preset-summary", scanInfo.chips);
11204 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11205 }
11206
11207 function applyScanPreset() {
11208 var info = scanPresetInfo[scanPreset.value];
11209 if (!info || !info.apply) return;
11210 mixedLinePolicy.value = info.apply.mixed;
11211 pythonDocstrings.checked = !!info.apply.docstrings;
11212 document.getElementById("generated_file_detection").value = info.apply.generated;
11213 document.getElementById("minified_file_detection").value = info.apply.minified;
11214 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11215 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11216 document.getElementById("binary_file_behavior").value = info.apply.binary;
11217 updateMixedPolicyUI();
11218 updatePythonDocstringUI();
11219 }
11220
11221 function applyArtifactPreset() {
11222 var enabled = { html: false, pdf: false };
11223 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11224 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11225 if (artifactPreset.value === "html_only") { enabled.html = true; }
11226 if (artifactPreset.value === "machine") { enabled.html = true; }
11227
11228 artifactCards.forEach(function (card) {
11229 var artifact = card.getAttribute("data-artifact");
11230 if (artifact === "json") return;
11231 var checked = !!enabled[artifact];
11232 var checkbox = card.querySelector(".artifact-checkbox");
11233 checkbox.checked = checked;
11234 card.classList.toggle("selected", checked);
11235 });
11236 }
11237
11238 function toggleArtifactCard(card) {
11239 var checkbox = card.querySelector(".artifact-checkbox");
11240 checkbox.checked = !checkbox.checked;
11241 card.classList.toggle("selected", checkbox.checked);
11242 }
11243
11244 function updateReview() {
11245 var scanSummary = document.getElementById("review-scan-summary");
11246 var countSummary = document.getElementById("review-count-summary");
11247 var artifactSummary = document.getElementById("review-artifact-summary");
11248 var outputSummary = document.getElementById("review-output-summary");
11249 var previewSummary = document.getElementById("review-preview-summary");
11250 var readinessSummary = document.getElementById("review-readiness-summary");
11251 var includeText = document.getElementById("include_globs").value.trim();
11252 var excludeText = document.getElementById("exclude_globs").value.trim();
11253 var sidePathPreview = document.getElementById("side-path-preview");
11254 var sideOutputPreview = document.getElementById("side-output-preview");
11255 var sideTitlePreview = document.getElementById("side-title-preview");
11256
11257 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11258 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11259 if (sideTitlePreview) {
11260 var rt = document.getElementById("report_title");
11261 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11262 }
11263
11264 scanSummary.innerHTML = ""
11265 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11266 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11267 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11268
11269 countSummary.innerHTML = ""
11270 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11271 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11272 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11273 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11274 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11275 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11276 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11277 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11278
11279 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11280 artifactSummary.innerHTML = ""
11281 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11282 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11283
11284 outputSummary.innerHTML = ""
11285 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11286 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11287
11288 if (previewSummary) {
11289 if (GIT_MODE) {
11290 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>';
11291 } else {
11292 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11293 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11294 var statMap = {};
11295 statButtons.forEach(function (button) {
11296 var valueNode = button.querySelector('.scope-stat-value');
11297 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11298 });
11299 previewSummary.innerHTML = ''
11300 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11301 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11302 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11303 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11304 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11305 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11306
11307 if (readinessSummary) {
11308 var selectedArtifactsCount = selectedArtifacts.length;
11309 readinessSummary.innerHTML = ''
11310 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11311 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11312 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11313 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11314 }
11315 } // end else (non-GIT_MODE)
11316 }
11317 }
11318
11319 function escapeHtml(value) {
11320 return String(value)
11321 .replace(/&/g, "&")
11322 .replace(/</g, "<")
11323 .replace(/>/g, ">")
11324 .replace(/"/g, """)
11325 .replace(/'/g, "'");
11326 }
11327
11328 function isPythonVisible() {
11329 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11330 }
11331
11332 function syncPythonVisibility() {
11333 var html = previewPanel.textContent || "";
11334 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11335 pythonWraps.forEach(function (node) {
11336 node.classList.toggle("hidden", !hasPython);
11337 });
11338 }
11339
11340 function attachPreviewInteractions() {
11341 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11342 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11343 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11344 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11345 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11346 var searchInput = previewPanel.querySelector("#explorer-search");
11347 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11348 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11349 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11350 var activeFilter = "all";
11351 var activeLanguage = "";
11352 var searchTerm = "";
11353 var currentSortKey = null;
11354 var currentSortOrder = "asc";
11355 var childRows = {};
11356
11357 rows.forEach(function (row) {
11358 var parentId = row.getAttribute("data-parent-id") || "";
11359 var rowId = row.getAttribute("data-row-id") || "";
11360 if (!childRows[parentId]) childRows[parentId] = [];
11361 childRows[parentId].push(rowId);
11362 });
11363
11364 function rowById(id) {
11365 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11366 }
11367
11368 function hasCollapsedAncestor(row) {
11369 var parentId = row.getAttribute("data-parent-id");
11370 while (parentId) {
11371 var parent = rowById(parentId);
11372 if (!parent) break;
11373 if (parent.getAttribute("data-expanded") === "false") return true;
11374 parentId = parent.getAttribute("data-parent-id");
11375 }
11376 return false;
11377 }
11378
11379 function updateToggleGlyph(row) {
11380 var toggle = row.querySelector(".tree-toggle");
11381 if (!toggle) return;
11382 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11383 }
11384
11385 function rowSortValue(row, key) {
11386 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11387 }
11388
11389 function updateSortButtons() {
11390 sortButtons.forEach(function (button) {
11391 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11392 var indicator = button.querySelector(".tree-sort-indicator");
11393 button.classList.toggle("active", isActive);
11394 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11395 if (indicator) {
11396 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11397 }
11398 });
11399 }
11400
11401 function sortSiblingRows() {
11402 if (!treeContainer) {
11403 updateSortButtons();
11404 return;
11405 }
11406
11407 var rowMap = {};
11408 var childrenMap = {};
11409 rows.forEach(function (row) {
11410 var rowId = row.getAttribute("data-row-id");
11411 var parentId = row.getAttribute("data-parent-id") || "";
11412 rowMap[rowId] = row;
11413 if (!childrenMap[parentId]) childrenMap[parentId] = [];
11414 childrenMap[parentId].push(rowId);
11415 });
11416
11417 Object.keys(childrenMap).forEach(function (parentId) {
11418 if (!parentId) return;
11419 childrenMap[parentId].sort(function (a, b) {
11420 var rowA = rowMap[a];
11421 var rowB = rowMap[b];
11422 if (!currentSortKey) {
11423 return Number(a) - Number(b);
11424 }
11425 var valueA = rowSortValue(rowA, currentSortKey);
11426 var valueB = rowSortValue(rowB, currentSortKey);
11427 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11428 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11429 var fallbackA = rowSortValue(rowA, "name");
11430 var fallbackB = rowSortValue(rowB, "name");
11431 if (fallbackA < fallbackB) return -1;
11432 if (fallbackA > fallbackB) return 1;
11433 return Number(a) - Number(b);
11434 });
11435 });
11436
11437 var orderedIds = [];
11438 function pushChildren(parentId) {
11439 (childrenMap[parentId] || []).forEach(function (childId) {
11440 orderedIds.push(childId);
11441 pushChildren(childId);
11442 });
11443 }
11444
11445 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11446 orderedIds.push(topId);
11447 pushChildren(topId);
11448 });
11449
11450 orderedIds.forEach(function (id) {
11451 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11452 });
11453 updateSortButtons();
11454 }
11455
11456 function updateLanguageButtons() {
11457 languageButtons.forEach(function (button) {
11458 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11459 var isActive = languageValue === activeLanguage;
11460 button.classList.toggle("active", isActive);
11461 });
11462 }
11463
11464 function rowSelfMatches(row) {
11465 var kind = row.getAttribute("data-kind");
11466 var status = row.getAttribute("data-status");
11467 var language = (row.getAttribute("data-language") || "").toLowerCase();
11468 var name = row.getAttribute("data-name-lower") || "";
11469 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11470 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11471 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11472 var passesLanguage = !activeLanguage || language === activeLanguage;
11473 return passesFilter && passesSearch && passesLanguage;
11474 }
11475
11476 function hasMatchingDescendant(rowId) {
11477 return (childRows[rowId] || []).some(function (childId) {
11478 var childRow = rowById(childId);
11479 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11480 });
11481 }
11482
11483 function rowMatches(row) {
11484 if (rowSelfMatches(row)) return true;
11485 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11486 }
11487
11488 function resetViewState() {
11489 activeFilter = "all";
11490 activeLanguage = "";
11491 searchTerm = "";
11492 currentSortKey = null;
11493 currentSortOrder = "asc";
11494 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11495 if (searchInput) searchInput.value = "";
11496 if (filterSelect) filterSelect.value = "all";
11497 updateLanguageButtons();
11498 }
11499
11500 function applyVisibility() {
11501 rows.forEach(function (row) {
11502 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11503 row.classList.toggle("hidden-by-filter", !visible);
11504 row.style.display = visible ? "grid" : "none";
11505 });
11506 buttons.forEach(function (button) {
11507 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11508 });
11509 if (filterSelect) filterSelect.value = activeFilter;
11510 }
11511
11512 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11513 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11514 var originalStats = {};
11515 buttons.forEach(function (btn) {
11516 var f = btn.getAttribute('data-filter');
11517 var v = btn.querySelector('.scope-stat-value');
11518 if (f && v) originalStats[f] = v.textContent;
11519 });
11520
11521 function applySubmoduleStats(statsJson) {
11522 try {
11523 var s = JSON.parse(statsJson);
11524 buttons.forEach(function (btn) {
11525 var f = btn.getAttribute('data-filter');
11526 var v = btn.querySelector('.scope-stat-value');
11527 if (!v) return;
11528 if (f === 'dir') v.textContent = s.dirs;
11529 else if (f === 'file') v.textContent = s.files;
11530 else if (f === 'supported') v.textContent = s.supported;
11531 else if (f === 'skipped') v.textContent = s.skipped;
11532 else if (f === 'unsupported') v.textContent = s.unsupported;
11533 });
11534 } catch (e) {}
11535 }
11536
11537 function restoreBaseRepoStats() {
11538 buttons.forEach(function (btn) {
11539 var f = btn.getAttribute('data-filter');
11540 var v = btn.querySelector('.scope-stat-value');
11541 if (v && originalStats[f]) v.textContent = originalStats[f];
11542 });
11543 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11544 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11545 }
11546
11547 submoduleChips.forEach(function (chip) {
11548 chip.addEventListener('click', function () {
11549 var statsJson = chip.getAttribute('data-sub-stats');
11550 if (!statsJson) return;
11551 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11552 chip.classList.add('active');
11553 applySubmoduleStats(statsJson);
11554 if (baseRepoBtn) baseRepoBtn.style.display = '';
11555 });
11556 });
11557
11558 if (baseRepoBtn) {
11559 baseRepoBtn.addEventListener('click', function () {
11560 restoreBaseRepoStats();
11561 resetViewState();
11562 sortSiblingRows();
11563 applyVisibility();
11564 });
11565 }
11566
11567 buttons.forEach(function (button) {
11568 button.addEventListener("click", function () {
11569 var filterValue = button.getAttribute("data-filter") || "all";
11570 if (filterValue === "reset-view") {
11571 restoreBaseRepoStats();
11572 resetViewState();
11573 sortSiblingRows();
11574 applyVisibility();
11575 return;
11576 }
11577 activeFilter = filterValue;
11578 applyVisibility();
11579 });
11580 });
11581
11582 rows.forEach(function (row) {
11583 updateToggleGlyph(row);
11584 var toggle = row.querySelector(".tree-toggle");
11585 if (toggle) {
11586 toggle.addEventListener("click", function () {
11587 var expanded = row.getAttribute("data-expanded") !== "false";
11588 row.setAttribute("data-expanded", expanded ? "false" : "true");
11589 updateToggleGlyph(row);
11590 applyVisibility();
11591 });
11592 }
11593 });
11594
11595 actionButtons.forEach(function (button) {
11596 button.addEventListener("click", function () {
11597 var action = button.getAttribute("data-explorer-action");
11598 if (action === "expand-all") {
11599 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11600 } else if (action === "collapse-all") {
11601 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11602 } else if (action === "clear-filters") {
11603 resetViewState();
11604 }
11605 sortSiblingRows();
11606 applyVisibility();
11607 });
11608 });
11609
11610 if (filterSelect) {
11611 filterSelect.addEventListener("change", function () {
11612 activeFilter = filterSelect.value || "all";
11613 applyVisibility();
11614 });
11615 }
11616
11617 languageButtons.forEach(function (button) {
11618 button.addEventListener("click", function () {
11619 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11620 updateLanguageButtons();
11621 applyVisibility();
11622 });
11623 });
11624
11625 sortButtons.forEach(function (button) {
11626 button.addEventListener("click", function () {
11627 var sortKey = button.getAttribute("data-sort-key");
11628 if (currentSortKey === sortKey) {
11629 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11630 } else {
11631 currentSortKey = sortKey;
11632 currentSortOrder = "asc";
11633 }
11634 sortSiblingRows();
11635 applyVisibility();
11636 });
11637 });
11638
11639 if (searchInput) {
11640 searchInput.addEventListener("input", function () {
11641 searchTerm = searchInput.value.trim().toLowerCase();
11642 applyVisibility();
11643 });
11644 }
11645
11646 updateLanguageButtons();
11647 sortSiblingRows();
11648 applyVisibility();
11649 }
11650
11651 function loadPreview() {
11652 if (!previewPanel || !pathInput) return;
11653 if (GIT_MODE) {
11654 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>';
11655 return;
11656 }
11657 var path = pathInput.value.trim();
11658 var zeroWarn = document.getElementById('zero-files-warning');
11659 if (!path) {
11660 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11661 if (zeroWarn) zeroWarn.style.display = 'none';
11662 return;
11663 }
11664 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11665 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11666 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11667 var previewUrl = "/preview?path=" + encodeURIComponent(path)
11668 + "&include_globs=" + encodeURIComponent(includeValue)
11669 + "&exclude_globs=" + encodeURIComponent(excludeValue);
11670 fetch(previewUrl)
11671 .then(function (response) { return response.text(); })
11672 .then(function (html) {
11673 previewPanel.innerHTML = html;
11674 attachPreviewInteractions();
11675 syncPythonVisibility();
11676 updateReview();
11677 setTimeout(collapseLanguagePills, 50);
11678 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11679 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11680 var sizeText = document.getElementById('project-size-text');
11681 var sizeBtn = document.getElementById('project-size-btn');
11682 if (sizeText && projectSize) {
11683 sizeText.textContent = 'Project size: ' + projectSize;
11684 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11685 } else if (sizeText) {
11686 sizeText.textContent = 'Project size: —';
11687 }
11688 if (zeroWarn) {
11689 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11690 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11691 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11692 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11693 if (supportedCount === 0 && fileCount > 0) {
11694 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).';
11695 zeroWarn.style.display = '';
11696 } else {
11697 zeroWarn.style.display = 'none';
11698 }
11699 }
11700 })
11701 .catch(function (err) {
11702 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11703 });
11704 }
11705
11706 function pickDirectory(targetInput, kind) {
11707 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11708 if (browseButton) browseButton.disabled = true;
11709
11710 if (previewPanel && targetInput === pathInput) {
11711 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11712 }
11713
11714 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
11715 .then(function (response) { return response.json(); })
11716 .then(function (data) {
11717 if (data && data.selected_path) {
11718 targetInput.value = data.selected_path;
11719
11720 if (targetInput === pathInput) {
11721 updateReportTitleFromPath();
11722 autoSetOutputDir(data.selected_path);
11723 fetchProjectHistory(data.selected_path);
11724 loadPreview();
11725 suggestCoverageFile(data.selected_path);
11726 }
11727
11728 updateReview();
11729 } else if (targetInput === pathInput) {
11730 // Cancelled — keep existing value and refresh preview with current path
11731 loadPreview();
11732 }
11733 })
11734 .catch(function () {
11735 window.alert("Directory picker request failed.");
11736 if (previewPanel && targetInput === pathInput) {
11737 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11738 }
11739 })
11740 .finally(function () {
11741 if (browseButton) browseButton.disabled = false;
11742 });
11743 }
11744
11745 if (themeToggle) {
11746 themeToggle.addEventListener("click", function () {
11747 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11748 applyTheme(nextTheme);
11749 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11750 });
11751 }
11752
11753 stepButtons.forEach(function (button) {
11754 button.addEventListener("click", function () {
11755 setStep(Number(button.getAttribute("data-step-target")));
11756 });
11757 });
11758
11759 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11760 button.addEventListener("click", function () {
11761 setStep(Number(button.getAttribute("data-step-target")) || 1);
11762 });
11763 });
11764
11765 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11766 button.addEventListener("click", function () {
11767 updateReview();
11768 setStep(Number(button.getAttribute("data-next")));
11769 });
11770 });
11771
11772 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11773 button.addEventListener("click", function () {
11774 setStep(Number(button.getAttribute("data-prev")));
11775 });
11776 });
11777
11778 document.addEventListener("keydown", function (e) {
11779 var tag = (document.activeElement || {}).tagName || "";
11780 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11781 if (e.altKey || e.ctrlKey || e.metaKey) return;
11782 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11783 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11784 });
11785
11786 if (useSamplePath) {
11787 useSamplePath.addEventListener("click", function () {
11788 pathInput.value = "tests/fixtures/basic";
11789 updateReportTitleFromPath();
11790 autoSetOutputDir("tests/fixtures/basic");
11791 loadPreview();
11792 suggestCoverageFile("tests/fixtures/basic");
11793 });
11794 }
11795
11796 if (useDefaultOutput) {
11797 useDefaultOutput.addEventListener("click", function () {
11798 delete outputDirInput.dataset.userEdited;
11799 autoSetOutputDir(pathInput ? pathInput.value : "");
11800 updateReview();
11801 });
11802 }
11803
11804 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11805 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11806 if (browseCoverage) {
11807 browseCoverage.addEventListener("click", function () {
11808 browseCoverage.disabled = true;
11809 var currentVal = coverageInput ? coverageInput.value : "";
11810 fetch("/pick-directory?kind=coverage¤t=" + encodeURIComponent(currentVal))
11811 .then(function (r) { return r.json(); })
11812 .then(function (d) {
11813 if (d && d.selected_path && coverageInput) {
11814 coverageInput.value = d.selected_path;
11815 setCovStatus("idle");
11816 }
11817 })
11818 .catch(function () {})
11819 .finally(function () { browseCoverage.disabled = false; });
11820 });
11821 }
11822
11823 function setCovStatus(state, opts) {
11824 if (!covScanStatus) return;
11825 opts = opts || {};
11826 covScanStatus.className = "cov-scan-status cov-scan-" + state;
11827 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11828 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>';
11829 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>';
11830 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>';
11831 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>';
11832 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11833 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11834 if (state === "scanning") {
11835 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11836 } else if (state === "found") {
11837 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11838 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
11839 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
11840 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
11841 } else if (state === "hint") {
11842 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11843 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
11844 html += '<div class="cov-scan-sub">Generate one with:</div>';
11845 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
11846 } else if (state === "none") {
11847 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
11848 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
11849 }
11850 html += '</div></div>';
11851 covScanStatus.innerHTML = html;
11852 if (state === "found") {
11853 var useBtn = covScanStatus.querySelector(".cov-scan-use");
11854 if (useBtn) useBtn.addEventListener("click", function () {
11855 if (coverageInput) coverageInput.value = "";
11856 covAutoFilled = false;
11857 setCovStatus("idle");
11858 });
11859 }
11860 }
11861
11862 function suggestCoverageFile(projectPath) {
11863 if (!coverageInput || !covScanStatus) return;
11864 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11865 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
11866 clearTimeout(coverageSuggestTimer);
11867 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
11868 setCovStatus("scanning");
11869 coverageSuggestTimer = setTimeout(function () {
11870 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
11871 .then(function (r) { return r.json(); })
11872 .then(function (d) {
11873 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11874 if (!d) { setCovStatus("none"); return; }
11875 if (d.found) {
11876 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
11877 setCovStatus("found", { found: d.found, tool: d.tool });
11878 } else if (d.tool && d.hint) {
11879 setCovStatus("hint", { tool: d.tool, hint: d.hint });
11880 } else {
11881 setCovStatus("none");
11882 }
11883 })
11884 .catch(function () { setCovStatus("idle"); });
11885 }, 600);
11886 }
11887
11888 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
11889
11890 if (coverageInput) coverageInput.addEventListener("input", function () {
11891 covAutoFilled = false;
11892 if (!this.value.trim()) setCovStatus("idle");
11893 });
11894
11895 // ── Language pill overflow: collapse to "+N more" chip ─────────────
11896 function collapseLanguagePills() {
11897 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
11898 rows.forEach(function(row) {
11899 // Remove any previous overflow chip
11900 var prev = row.querySelector('.lang-overflow-chip');
11901 if (prev) prev.remove();
11902 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
11903 pills.forEach(function(p) { p.style.display = ''; });
11904 if (!pills.length) return;
11905
11906 // Measure after restoring all pills
11907 var containerRight = row.getBoundingClientRect().right;
11908 var hidden = [];
11909 for (var i = pills.length - 1; i >= 1; i--) {
11910 var rect = pills[i].getBoundingClientRect();
11911 if (rect.right > containerRight + 2) {
11912 hidden.unshift(pills[i]);
11913 pills[i].style.display = 'none';
11914 } else {
11915 break;
11916 }
11917 }
11918
11919 if (hidden.length) {
11920 var chip = document.createElement('button');
11921 chip.type = 'button';
11922 chip.className = 'language-pill lang-overflow-chip';
11923 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
11924 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
11925 row.appendChild(chip);
11926 }
11927 });
11928 }
11929
11930 // Run after preview loads (preview panel populates language pills)
11931 var _origLoadPreviewCb = window.__previewLoaded;
11932 document.addEventListener('previewLoaded', collapseLanguagePills);
11933 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
11934 setTimeout(collapseLanguagePills, 400);
11935
11936 // ── Project history & output dir auto-set ──────────────────────────
11937 var wsOutputRoot = document.getElementById("ws-output-root");
11938 var wsScanCount = document.getElementById("ws-scan-count");
11939 var wsLastScan = document.getElementById("ws-last-scan");
11940 var historyBadge = document.getElementById("path-history-badge");
11941 var historyTimer = null;
11942
11943 var wsOutputLink = document.getElementById("ws-output-link");
11944 function syncStripOutputRoot() {
11945 var val = outputDirInput ? outputDirInput.value : "";
11946 var display = val || "project/sloc";
11947 if (wsOutputRoot) wsOutputRoot.textContent = display;
11948 if (wsOutputLink) wsOutputLink.dataset.folder = val;
11949 }
11950
11951 function autoSetOutputDir(projectPath) {
11952 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
11953 if (GIT_MODE && GIT_OUTPUT_DIR) {
11954 outputDirInput.value = GIT_OUTPUT_DIR;
11955 syncStripOutputRoot();
11956 updateReview();
11957 return;
11958 }
11959 if (!projectPath || !projectPath.trim()) return;
11960 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
11961 outputDirInput.value = cleaned + "/sloc";
11962 syncStripOutputRoot();
11963 updateReview();
11964 }
11965
11966 var wsBranch = document.getElementById("ws-branch");
11967
11968 function fetchProjectHistory(projectPath) {
11969 if (!projectPath || !projectPath.trim()) {
11970 if (wsScanCount) wsScanCount.textContent = "—";
11971 if (wsLastScan) wsLastScan.textContent = "—";
11972 if (wsBranch) wsBranch.textContent = "—";
11973 if (historyBadge) historyBadge.style.display = "none";
11974 return;
11975 }
11976 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
11977 .then(function (r) { return r.ok ? r.json() : null; })
11978 .then(function (data) {
11979 if (!data) return;
11980 var countStr = data.scan_count > 0
11981 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
11982 : "never";
11983 var tsStr = data.last_scan_timestamp
11984 ? data.last_scan_timestamp.replace(" UTC","")
11985 : "—";
11986 if (wsScanCount) wsScanCount.textContent = countStr;
11987 if (wsLastScan) wsLastScan.textContent = tsStr;
11988 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
11989 if (data.scan_count > 0) {
11990 if (historyBadge) {
11991 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
11992 historyBadge.textContent = data.scan_count + " previous scan" +
11993 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
11994 "Last: " + (data.last_scan_timestamp || "—") +
11995 " — " + (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.";
11996 historyBadge.className = "path-history-badge found";
11997 historyBadge.style.display = "";
11998 }
11999 } else {
12000 if (historyBadge) historyBadge.style.display = "none";
12001 }
12002 })
12003 .catch(function () {});
12004 }
12005
12006 function onPathChange() {
12007 var val = pathInput ? pathInput.value : "";
12008 updateReportTitleFromPath();
12009 autoSetOutputDir(val);
12010 updateSidebarSummary();
12011 clearTimeout(historyTimer);
12012 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
12013 if (previewTimer) clearTimeout(previewTimer);
12014 previewTimer = setTimeout(loadPreview, 280);
12015 suggestCoverageFile(val);
12016 }
12017
12018 if (pathInput) {
12019 pathInput.addEventListener("input", onPathChange);
12020 }
12021
12022 if (outputDirInput) {
12023 outputDirInput.addEventListener("input", function () {
12024 outputDirInput.dataset.userEdited = "1";
12025 syncStripOutputRoot();
12026 updateReview();
12027 });
12028 }
12029
12030 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
12031 if (!node) return;
12032 node.addEventListener("input", function () {
12033 updateReview();
12034 if (previewTimer) clearTimeout(previewTimer);
12035 previewTimer = setTimeout(loadPreview, 280);
12036 });
12037 });
12038
12039 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
12040 var node = document.getElementById(id);
12041 if (node) node.addEventListener("change", updateReview);
12042 });
12043
12044 if (reportTitleInput) {
12045 reportTitleInput.addEventListener("input", function () {
12046 reportTitleTouched = reportTitleInput.value.trim().length > 0;
12047 updateReportTitleFromPath();
12048 updateReview();
12049 });
12050 }
12051
12052 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
12053 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
12054 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
12055 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
12056
12057 artifactCards.forEach(function (card) {
12058 card.addEventListener("click", function () {
12059 if (card.classList.contains("artifact-locked")) return;
12060 toggleArtifactCard(card);
12061 updateReview();
12062 });
12063 });
12064
12065 if (coverageInput) {
12066 coverageInput.addEventListener("input", function () {
12067 if (coverageInput.value.trim()) setCovStatus("idle");
12068 });
12069 }
12070
12071 if (form && loading && submitButton) {
12072 form.addEventListener("submit", function (e) {
12073 e.preventDefault();
12074 submitButton.disabled = true;
12075 submitButton.textContent = "Scanning...";
12076 startAsyncAnalysis(new FormData(form));
12077 });
12078 }
12079
12080 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12081 btn.addEventListener('click', function () {
12082 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12083 if (!folder) return;
12084 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12085 });
12086 });
12087
12088 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12089 if (wsOutputLink) {
12090 wsOutputLink.addEventListener('click', function () {
12091 var folder = wsOutputLink.dataset.folder || '';
12092 if (!folder) return;
12093 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12094 });
12095 }
12096
12097 loadSavedTheme();
12098 updateMixedPolicyUI();
12099 updatePythonDocstringUI();
12100 applyScanPreset();
12101 updatePresetDescriptions();
12102 applyArtifactPreset();
12103 updateReview();
12104 updateScrollProgress(); // initialise bar to 0% (step 1)
12105 window.addEventListener("scroll", updateScrollProgress, { passive: true });
12106 onPathChange(); // seed output dir, history badge, and preview from initial path
12107 loadPreview();
12108 updateStepNav(1);
12109
12110 // Restore step from URL hash on initial load (e.g., back-forward cache)
12111 (function() {
12112 var hashMatch = location.hash.match(/^#step([1-4])$/);
12113 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12114 })();
12115
12116 (function randomizeWatermarks() {
12117 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12118 if (!wms.length) return;
12119 var placed = [];
12120 function tooClose(top, left) {
12121 for (var i = 0; i < placed.length; i++) {
12122 var dt = Math.abs(placed[i][0] - top);
12123 var dl = Math.abs(placed[i][1] - left);
12124 if (dt < 16 && dl < 12) return true;
12125 }
12126 return false;
12127 }
12128 function pick(leftBand) {
12129 for (var attempt = 0; attempt < 50; attempt++) {
12130 var top = Math.random() * 88 + 2;
12131 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12132 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12133 }
12134 var top = Math.random() * 88 + 2;
12135 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12136 placed.push([top, left]);
12137 return [top, left];
12138 }
12139 var half = Math.floor(wms.length / 2);
12140 wms.forEach(function (img, i) {
12141 var pos = pick(i < half);
12142 var size = Math.floor(Math.random() * 80 + 110);
12143 var rot = (Math.random() * 360).toFixed(1);
12144 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12145 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;
12146 });
12147 })();
12148
12149 (function spawnCodeParticles() {
12150 var container = document.getElementById('code-particles');
12151 if (!container) return;
12152 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'];
12153 for (var i = 0; i < 38; i++) {
12154 (function(idx) {
12155 var el = document.createElement('span');
12156 el.className = 'code-particle';
12157 el.textContent = snippets[idx % snippets.length];
12158 var left = Math.random() * 94 + 2;
12159 var top = Math.random() * 88 + 6;
12160 var dur = (Math.random() * 10 + 9).toFixed(1);
12161 var delay = (Math.random() * 18).toFixed(1);
12162 var rot = (Math.random() * 26 - 13).toFixed(1);
12163 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12164 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';
12165 container.appendChild(el);
12166 })(i);
12167 }
12168 })();
12169 })();
12170 </script>
12171 <script nonce="{{ csp_nonce }}">
12172 (function () {
12173 var raw = {{ prefill_json|safe }};
12174 if (!raw || typeof raw !== 'object' || !raw.path) return;
12175 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12176 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12177 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12178 setVal('path-input', raw.path || '');
12179 setVal('include-globs', raw.include_globs || '');
12180 setVal('exclude-globs', raw.exclude_globs || '');
12181 setVal('output-dir', raw.output_dir || '');
12182 setVal('report-title', raw.report_title || '');
12183 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12184 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12185 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12186 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12187 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12188 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12189 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12190 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12191 setChecked('generate-html', raw.generate_html !== false);
12192 setChecked('generate-pdf', !!raw.generate_pdf);
12193 // Trigger dynamic UI updates after pre-fill.
12194 setTimeout(function () {
12195 var pathEl = document.getElementById('path-input');
12196 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12197 var policyEl = document.getElementById('mixed-line-policy');
12198 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12199 }, 80);
12200 })();
12201 </script>
12202 <script nonce="{{ csp_nonce }}">
12203 (function(){
12204 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'}];
12205 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);});}
12206 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12207 function init(){
12208 var btn=document.getElementById('settings-btn');if(!btn)return;
12209 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12210 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>';
12211 document.body.appendChild(m);
12212 var g=document.getElementById('scheme-grid');
12213 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);});
12214 var cl=document.getElementById('settings-close');
12215 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);
12216 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');});
12217 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12218 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12219 }
12220 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12221 }());
12222 </script>
12223 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12224 <div class="wb-ftip-arrow"></div>
12225 <span id="wb-ftip-text"></span>
12226 </div>
12227 <script nonce="{{ csp_nonce }}">(function(){
12228 var tip=document.getElementById('wb-ftip');
12229 var txt=document.getElementById('wb-ftip-text');
12230 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12231 if(!tip||!txt)return;
12232 function pos(el){
12233 var r=el.getBoundingClientRect();
12234 tip.style.display='block';
12235 var tw=tip.offsetWidth;
12236 var lx=r.left+r.width/2-tw/2;
12237 if(lx<8)lx=8;
12238 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12239 tip.style.left=lx+'px';
12240 tip.style.top=(r.bottom+8)+'px';
12241 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';}
12242 }
12243 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12244 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12245 el.addEventListener('mouseleave',function(){tip.style.display='none';});
12246 });
12247 })();
12248 (function(){
12249 function fixArtifactHintSpacing(){
12250 var grid=document.querySelector('.artifact-grid');
12251 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12252 }
12253 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12254 }());
12255 </script>
12256 <footer class="site-footer">
12257 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12258 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12259 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12260 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12261 · <a href="/api-docs" rel="noopener">REST API</a>
12262 </footer>
12263</body>
12264</html>
12265"##,
12266 ext = "html"
12267)]
12268struct IndexTemplate {
12269 version: &'static str,
12270 prefill_json: String,
12271 csp_nonce: String,
12272 git_repo: String,
12273 git_ref: String,
12274 git_label_json: String,
12275 git_output_dir_json: String,
12276}
12277
12278#[derive(Template)]
12281#[template(
12282 source = r##"
12283<!doctype html>
12284<html lang="en">
12285<head>
12286 <meta charset="utf-8">
12287 <meta name="viewport" content="width=device-width, initial-scale=1">
12288 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12289 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12290 <style nonce="{{ csp_nonce }}">
12291 :root {
12292 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12293 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12294 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12295 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12296 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12297 }
12298 body.dark-theme {
12299 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12300 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12301 }
12302 *{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);}
12303 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12304 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12305 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12306 .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;}
12307 @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));}}
12308 .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);}
12309 .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12310 .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));}
12311 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12312 .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;}
12313 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12314 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12315 @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; } }
12316 .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;}
12317 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12318 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12319 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12320 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12321 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12322 .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;}
12323 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12324 .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);}
12325 .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;}
12326 .settings-close:hover{color:var(--text);background:var(--surface-2);}
12327 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12328 .settings-modal-body{padding:14px 16px 16px;}
12329 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12330 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12331 .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;}
12332 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12333 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12334 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12335 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12336 .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;}
12337 .tz-select:focus{border-color:var(--oxide);}
12338 .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;}
12339 .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;}
12340 .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12341 .hero{text-align:center;margin:0 auto 18px;}
12342 .hero-logo-wrap{display:inline-block;cursor:default;}
12343 .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;}
12344 .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;}
12345 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12346 .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;}
12347 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%);}
12348 .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;
12349 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12350 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12351 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;}
12352 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12353 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12354 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;}
12355 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:2.5em;opacity:0;}
12356 .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;}
12357 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12358 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12359 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12360 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12361 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12362 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12363 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12364 .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;}
12365 .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;}
12366 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12367 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12368 .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);}
12369 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12370 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12371 .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);}
12372 .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);}
12373 .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);}
12374 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12375 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12376 .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;}
12377 body.dark-theme .action-card-cta{color:var(--oxide);}
12378 .action-card.view .action-card-cta{color:var(--accent-2);}
12379 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12380 .action-card.compare .action-card-cta{color:#7c3aed;}
12381 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12382 .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);}
12383 .action-card.git-tools .action-card-cta{color:#15803d;}
12384 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12385 .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);}
12386 .action-card.trend .action-card-cta{color:#0e7490;}
12387 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12388 .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);}
12389 .action-card.automation .action-card-cta{color:#b45309;}
12390 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12391 .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);}
12392 .action-card.test-metrics .action-card-cta{color:#be185d;}
12393 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12394 .action-card:hover .action-card-cta{gap:12px;}
12395 .action-card.card-split{flex-direction:row;align-items:stretch;}
12396 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12397 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12398 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12399 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12400 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12401 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12402 .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;}
12403 .ac-badge.active{opacity:1;}
12404 .ac-badge.github{border-color:#555;color:#555;}
12405 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12406 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12407 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12408 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12409 body.dark-theme .ac-right-row{color:var(--muted);}
12410 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12411 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12412 .divider{height:1px;background:var(--line);margin:32px 0;}
12413 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12414 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12415 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12416 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12417 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12418 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12419 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12420 body.dark-theme .info-chip-val{color:var(--oxide);}
12421 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12422 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12423 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12424 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12425 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12426 border:6px solid transparent;border-top-color:var(--text);}
12427 .info-chip:hover .info-chip-tip{display:block;}
12428 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12429 .chip-slide.fading{filter:blur(5px);opacity:0;}
12430 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12431 .site-footer a{color:var(--muted);}
12432 .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;}
12433 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12434 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12435 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12436 .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;}
12437 .lan-badge.local{background:var(--oxide-2);}
12438 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12439 .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);}
12440 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12441 .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;}
12442 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12443 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12444 .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;}
12445 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12446 .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;}
12447 .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);}
12448 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12449 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12450 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12451 .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;}
12452 </style>
12453</head>
12454<body>
12455 <div class="background-watermarks" aria-hidden="true">
12456 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12457 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12458 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12459 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12460 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12461 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12462 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12463 </div>
12464 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12465 <div class="top-nav">
12466 <div class="top-nav-inner">
12467 <a class="brand" href="/">
12468 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12469 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12470 </a>
12471 <div class="nav-right">
12472 <a class="nav-pill" href="/">Home</a>
12473 <div class="nav-dropdown">
12474 <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>
12475 <div class="nav-dropdown-menu">
12476 <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>
12477 </div>
12478 </div>
12479 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12480 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12481 <div class="nav-dropdown">
12482 <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>
12483 <div class="nav-dropdown-menu">
12484 <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>
12485 </div>
12486 </div>
12487 <div class="server-status-wrap">
12488 {% if server_mode %}
12489 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12490 <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>
12491 {% else %}
12492 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12493 <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12494 {% endif %}
12495 </div>
12496 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12497 <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>
12498 </button>
12499 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12500 <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>
12501 <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>
12502 </button>
12503 </div>
12504 </div>
12505 </div>
12506
12507 <div class="page">
12508 <div class="hero">
12509 <div class="hero-logo-wrap" id="hero-logo-wrap">
12510 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12511 </div>
12512 <div class="hero-logo-shadow"></div>
12513 <div class="hero-title-wrap">
12514 <div class="hero-title-aura" aria-hidden="true"></div>
12515 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12516 </div>
12517 <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>
12518 </div>
12519
12520 <div class="card-sections">
12521
12522 <div>
12523 <div class="card-section-label">Analysis</div>
12524 <div class="card-section-grid-2">
12525 <a class="action-card scan card-split" href="/scan-setup">
12526 <div class="action-card-left">
12527 <div class="action-card-icon">
12528 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12529 </div>
12530 <div class="action-card-title">Scan Project</div>
12531 <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>
12532 <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>
12533 </div>
12534 <div class="action-card-sep"></div>
12535 <div class="action-card-right">
12536 <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>
12537 <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>
12538 <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>
12539 <div class="ac-right-stat" id="acp-scan-stat"></div>
12540 </div>
12541 </a>
12542 <a class="action-card test-metrics card-split" href="/test-metrics">
12543 <div class="action-card-left">
12544 <div class="action-card-icon">
12545 <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>
12546 </div>
12547 <div class="action-card-title">Test Metrics</div>
12548 <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>
12549 <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>
12550 </div>
12551 <div class="action-card-sep"></div>
12552 <div class="action-card-right">
12553 <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>
12554 <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>
12555 <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>
12556 <div class="ac-right-stat" id="acp-test-stat"></div>
12557 </div>
12558 </a>
12559 </div>
12560 </div>
12561
12562 <div>
12563 <div class="card-section-label">Reports & Insights</div>
12564 <div class="card-section-grid-3">
12565 <a class="action-card view" href="/view-reports">
12566 <div class="action-card-icon">
12567 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12568 </div>
12569 <div class="action-card-title">View Reports</div>
12570 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12571 <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>
12572 </a>
12573 <a class="action-card compare" href="/compare-scans">
12574 <div class="action-card-icon">
12575 <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>
12576 </div>
12577 <div class="action-card-title">Compare Scans</div>
12578 <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>
12579 <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>
12580 </a>
12581 <a class="action-card trend" href="/trend-reports">
12582 <div class="action-card-icon">
12583 <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>
12584 </div>
12585 <div class="action-card-title">Trend Report</div>
12586 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12587 <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>
12588 </a>
12589 </div>
12590 </div>
12591
12592 <div>
12593 <div class="card-section-label">Developer Tools</div>
12594 <div class="card-section-grid-2">
12595 <a class="action-card git-tools card-split" href="/git-browser">
12596 <div class="action-card-left">
12597 <div class="action-card-icon">
12598 <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>
12599 </div>
12600 <div class="action-card-title">Git Browser</div>
12601 <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>
12602 <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>
12603 </div>
12604 <div class="action-card-sep"></div>
12605 <div class="action-card-right">
12606 <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>
12607 <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>
12608 <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>
12609 </div>
12610 </a>
12611 <a class="action-card automation card-split" href="/integrations">
12612 <div class="action-card-left">
12613 <div class="action-card-icon">
12614 <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>
12615 </div>
12616 <div class="action-card-title">Integrations</div>
12617 <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>
12618 <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>
12619 </div>
12620 <div class="action-card-sep"></div>
12621 <div class="action-card-right">
12622 <div class="ac-badges-grid">
12623 <span class="ac-badge github" id="acp-gh">GitHub</span>
12624 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
12625 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
12626 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12627 </div>
12628 <div class="ac-right-stat" id="acp-int-stat"></div>
12629 </div>
12630 </a>
12631 </div>
12632 </div>
12633
12634 </div>
12635
12636 {% if server_mode %}
12637 <div class="lan-card server">
12638 <div class="lan-card-header">
12639 <span class="lan-badge">LAN server</span>
12640 Accessible on your network
12641 </div>
12642 {% if let Some(ip) = lan_ip %}
12643 <div class="lan-url-row">
12644 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12645 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12646 <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>
12647 Copy URL
12648 </button>
12649 </div>
12650 <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12651 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
12652 {% else %}
12653 <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>
12654 {% endif %}
12655 </div>
12656 {% endif %}
12657
12658 <div class="divider"></div>
12659
12660 <div class="info-strip">
12661 <div class="info-chip">
12662 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12663 <div class="chip-slide">
12664 <div class="info-chip-val">41</div>
12665 <div class="info-chip-label">Languages</div>
12666 </div>
12667 </div>
12668 <div class="info-chip">
12669 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12670 <div class="chip-slide">
12671 <div class="info-chip-val">100%</div>
12672 <div class="info-chip-label">Self-contained</div>
12673 </div>
12674 </div>
12675 <div class="info-chip">
12676 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12677 <div class="chip-slide">
12678 <div class="info-chip-val">HTML+PDF</div>
12679 <div class="info-chip-label">Exportable reports</div>
12680 </div>
12681 </div>
12682 <div class="info-chip">
12683 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12684 <div class="chip-slide">
12685 <div class="info-chip-val">Webhook</div>
12686 <div class="info-chip-label">3 platforms</div>
12687 </div>
12688 </div>
12689 <div class="info-chip">
12690 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12691 <div class="chip-slide">
12692 <div class="info-chip-val">IEEE</div>
12693 <div class="info-chip-label">1045-1992</div>
12694 </div>
12695 </div>
12696 </div>
12697
12698 {% if lan_ip.is_none() %}
12699 <div class="lan-local-hint">
12700 <strong>Want teammates on the same network to access this?</strong><br>
12701 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
12702 </div>
12703 {% endif %}
12704 </div>
12705
12706 <footer class="site-footer">
12707 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12708 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12709 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12710 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12711 · <a href="/api-docs" rel="noopener">REST API</a>
12712 </footer>
12713
12714 <script nonce="{{ csp_nonce }}">
12715 (function () {
12716 var storageKey = 'oxide-sloc-theme';
12717 var body = document.body;
12718 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12719 var toggle = document.getElementById('theme-toggle');
12720 if (toggle) toggle.addEventListener('click', function () {
12721 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12722 body.classList.toggle('dark-theme', next === 'dark');
12723 try { localStorage.setItem(storageKey, next); } catch(e) {}
12724 });
12725 var copyBtn = document.getElementById('lan-copy-btn');
12726 if (copyBtn) copyBtn.addEventListener('click', function() {
12727 var btn = this;
12728 var el = document.getElementById('lan-url-val');
12729 if (!el) return;
12730 var url = el.textContent.trim();
12731 if (navigator.clipboard) {
12732 navigator.clipboard.writeText(url).then(function() {
12733 var orig = btn.innerHTML;
12734 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!';
12735 setTimeout(function() { btn.innerHTML = orig; }, 1800);
12736 });
12737 }
12738 });
12739 (function randomizeWatermarks() {
12740 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12741 if (!wms.length) return;
12742 var placed = [];
12743 function tooClose(top, left) {
12744 for (var i = 0; i < placed.length; i++) {
12745 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12746 if (dt < 16 && dl < 12) return true;
12747 }
12748 return false;
12749 }
12750 function pick(leftBand) {
12751 for (var attempt = 0; attempt < 50; attempt++) {
12752 var top = Math.random() * 88 + 2;
12753 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12754 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12755 }
12756 var top = Math.random() * 88 + 2;
12757 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12758 placed.push([top, left]); return [top, left];
12759 }
12760 var half = Math.floor(wms.length / 2);
12761 wms.forEach(function (img, i) {
12762 var pos = pick(i < half);
12763 var size = Math.floor(Math.random() * 100 + 120);
12764 var rot = (Math.random() * 360).toFixed(1);
12765 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12766 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;
12767 });
12768 })();
12769
12770 (function spawnCodeParticles() {
12771 var container = document.getElementById('code-particles');
12772 if (!container) return;
12773 var snippets = [
12774 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12775 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12776 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12777 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12778 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12779 ];
12780 var count = 38;
12781 for (var i = 0; i < count; i++) {
12782 (function(idx) {
12783 var el = document.createElement('span');
12784 el.className = 'code-particle';
12785 var text = snippets[idx % snippets.length];
12786 el.textContent = text;
12787 var left = Math.random() * 94 + 2;
12788 var top = Math.random() * 88 + 6;
12789 var dur = (Math.random() * 10 + 9).toFixed(1);
12790 var delay = (Math.random() * 18).toFixed(1);
12791 var rot = (Math.random() * 26 - 13).toFixed(1);
12792 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12793 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12794 + '--rot:' + rot + 'deg;--op:' + op + ';'
12795 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12796 container.appendChild(el);
12797 })(i);
12798 }
12799 })();
12800 (function heroAnimations() {
12801 var sub = document.getElementById('hero-subtitle');
12802 if (sub) {
12803 var full = sub.textContent.trim();
12804 sub.textContent = '';
12805 sub.style.opacity = '1';
12806 var cursor = document.createElement('span');
12807 cursor.className = 'hero-cursor';
12808 sub.appendChild(cursor);
12809 var i = 0;
12810 setTimeout(function() {
12811 var iv = setInterval(function() {
12812 if (i < full.length) {
12813 sub.insertBefore(document.createTextNode(full[i]), cursor);
12814 i++;
12815 } else {
12816 clearInterval(iv);
12817 setTimeout(function() {
12818 cursor.style.transition = 'opacity 1s ease';
12819 cursor.style.opacity = '0';
12820 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
12821 }, 2400);
12822 }
12823 }, 11);
12824 }, 374);
12825 }
12826 })();
12827 (function logoBob() {
12828 var logo = document.querySelector('.hero-logo');
12829 var shadow = document.querySelector('.hero-logo-shadow');
12830 if (!logo) return;
12831 var cycleStart = null, cycleDur = 3600;
12832 var peakY = -14, peakScale = 1.07, peakRot = 0;
12833 function newCycle() {
12834 cycleDur = 3000 + Math.random() * 1840;
12835 peakY = -(9 + Math.random() * 13.8);
12836 peakScale = 1.04 + Math.random() * 0.081;
12837 peakRot = (Math.random() * 11.5 - 5.75);
12838 }
12839 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
12840 newCycle();
12841 function frame(ts) {
12842 if (cycleStart === null) cycleStart = ts;
12843 var t = (ts - cycleStart) / cycleDur;
12844 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
12845 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
12846 var y = peakY * phase;
12847 var sc = 1 + (peakScale - 1) * phase;
12848 var rot = peakRot * Math.sin(Math.PI * phase);
12849 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
12850 if (shadow) {
12851 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
12852 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
12853 }
12854 requestAnimationFrame(frame);
12855 }
12856 requestAnimationFrame(frame);
12857 })();
12858 (function mouseEffects() {
12859 var heroTitle = document.getElementById('hero-title');
12860 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
12861 function tick() {
12862 raf = null;
12863 if (heroTitle) {
12864 var r = heroTitle.getBoundingClientRect();
12865 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
12866 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
12867 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
12868 }
12869 }
12870 document.addEventListener('mousemove', function(e) {
12871 mx = e.clientX; my = e.clientY;
12872 if (!raf) raf = requestAnimationFrame(tick);
12873 });
12874 document.addEventListener('mouseleave', function() {
12875 if (heroTitle) {
12876 heroTitle.style.transition = 'transform 0.5s ease';
12877 heroTitle.style.transform = '';
12878 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
12879 }
12880 });
12881 document.querySelectorAll('.action-card').forEach(function(card) {
12882 card.addEventListener('mousemove', function(e) {
12883 var rect = card.getBoundingClientRect();
12884 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
12885 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
12886 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
12887 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
12888 });
12889 card.addEventListener('mouseleave', function() {
12890 card.style.transition = '';
12891 card.style.transform = '';
12892 });
12893 });
12894 })();
12895 (function chipSlideshow() {
12896 var slides = [
12897 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
12898 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
12899 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
12900 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
12901 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
12902 ];
12903 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
12904 var indices = [0,0,0,0,0];
12905 var paused = [false,false,false,false,false];
12906 chips.forEach(function(chip, i) {
12907 chip.addEventListener('mouseenter', function() { paused[i] = true; });
12908 chip.addEventListener('mouseleave', function() { paused[i] = false; });
12909 });
12910 function advance(i) {
12911 if (paused[i]) return;
12912 var chip = chips[i];
12913 var inner = chip.querySelector('.chip-slide');
12914 if (!inner) return;
12915 inner.classList.add('fading');
12916 setTimeout(function() {
12917 indices[i] = (indices[i] + 1) % slides[i].length;
12918 var s = slides[i][indices[i]];
12919 chip.querySelector('.info-chip-val').textContent = s.v;
12920 chip.querySelector('.info-chip-label').textContent = s.l;
12921 inner.classList.remove('fading');
12922 }, 720);
12923 }
12924 setInterval(function() {
12925 chips.forEach(function(chip, i) { advance(i); });
12926 }, 6000);
12927 })();
12928 (function cardLiveData() {
12929 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
12930 var el = document.getElementById('acp-scan-stat');
12931 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
12932 }).catch(function(){});
12933 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
12934 var el = document.getElementById('acp-test-stat');
12935 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
12936 }).catch(function(){});
12937 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
12938 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
12939 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
12940 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
12941 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
12942 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
12943 var stat = document.getElementById('acp-int-stat');
12944 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
12945 }).catch(function(){});
12946 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
12947 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
12948 }).catch(function(){});
12949 })();
12950 })();
12951 </script>
12952 <script nonce="{{ csp_nonce }}">
12953 (function(){
12954 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'}];
12955 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);});}
12956 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12957 function init(){
12958 var btn=document.getElementById('settings-btn');if(!btn)return;
12959 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12960 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>';
12961 document.body.appendChild(m);
12962 var g=document.getElementById('scheme-grid');
12963 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);});
12964 var cl=document.getElementById('settings-close');
12965 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);
12966 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');});
12967 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12968 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12969 }
12970 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12971 }());
12972 </script>
12973</body>
12974</html>
12975"##,
12976 ext = "html"
12977)]
12978struct SplashTemplate {
12979 csp_nonce: String,
12980 server_mode: bool,
12981 lan_ip: Option<String>,
12982 port: u16,
12983 version: &'static str,
12984}
12985
12986#[derive(Template)]
12989#[template(
12990 source = r##"
12991<!doctype html>
12992<html lang="en">
12993<head>
12994 <meta charset="utf-8">
12995 <meta name="viewport" content="width=device-width, initial-scale=1">
12996 <title>OxideSLOC — Start a Scan</title>
12997 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12998 <style nonce="{{ csp_nonce }}">
12999 :root {
13000 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
13001 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13002 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13003 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13004 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
13005 }
13006 body.dark-theme {
13007 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
13008 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
13009 }
13010 *{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);}
13011 .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);}
13012 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
13013 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
13014 .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));}
13015 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
13016 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
13017 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
13018 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
13019 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13020 @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; } }
13021 .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;}
13022 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
13023 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
13024 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13025 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13026 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13027 .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;}
13028 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13029 .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);}
13030 .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;}
13031 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13032 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13033 .settings-modal-body{padding:14px 16px 16px;}
13034 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13035 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13036 .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;}
13037 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13038 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13039 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13040 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13041 .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;}
13042 .tz-select:focus{border-color:var(--oxide);}
13043 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13044 .page-header{text-align:center;margin-bottom:16px;}
13045 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13046 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13047 /* Cards */
13048 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13049 .option-card-wrap{position:relative;}
13050 .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;}
13051 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13052 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13053 .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;}
13054 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13055 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13056 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13057 .card-top-row{display:flex;align-items:center;gap:20px;}
13058 /* Two-column layout inside each card */
13059 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13060 .card-left{display:flex;align-items:flex-start;min-width:0;}
13061 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13062 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13063 .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);}
13064 .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);}
13065 .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);}
13066 .card-text{min-width:0;}
13067 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13068 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13069 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13070 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13071 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13072 /* Right CTA column */
13073 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13074 .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;}
13075 /* Re-scan count badge */
13076 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13077 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13078 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13079 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13080 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13081 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13082 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13083 body.dark-theme .btn-secondary{color:var(--oxide);}
13084 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13085 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13086 /* File input overlay — must be full-width so it aligns with other card-right buttons */
13087 .file-input-wrap{position:relative;width:100%;}
13088 .file-input-wrap .btn{width:100%;}
13089 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13090 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13091 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13092 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13093 .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;}
13094 @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));}}
13095 /* Recent list (card 3 — full-width section below header) */
13096 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13097 .recent-list{display:flex;flex-direction:column;gap:8px;}
13098 .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;}
13099 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13100 .recent-item-info{flex:1;min-width:0;}
13101 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13102 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13103 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13104 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13105 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13106 .site-footer a{color:var(--muted);}
13107 @media(max-width:680px){
13108 .card-body{grid-template-columns:1fr;}
13109 .card-right{flex-direction:row;flex-wrap:wrap;}
13110 .btn{flex:1;}
13111 }
13112 .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;}
13113 .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;}
13114 .server-online-pill{cursor:default;}
13115 </style>
13116</head>
13117<body>
13118 <div class="background-watermarks" aria-hidden="true">
13119 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13120 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13121 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13122 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13123 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13124 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13125 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13126 </div>
13127 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13128 <div class="top-nav">
13129 <div class="top-nav-inner">
13130 <a class="brand" href="/">
13131 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13132 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13133 </a>
13134 <div class="nav-right">
13135 <a class="nav-pill" href="/">Home</a>
13136 <div class="nav-dropdown">
13137 <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>
13138 <div class="nav-dropdown-menu">
13139 <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>
13140 </div>
13141 </div>
13142 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13143 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13144 <div class="nav-dropdown">
13145 <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>
13146 <div class="nav-dropdown-menu">
13147 <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>
13148 </div>
13149 </div>
13150 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13151 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13152 <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>
13153 </button>
13154 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13155 <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>
13156 <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>
13157 </button>
13158 </div>
13159 </div>
13160 </div>
13161
13162 <div class="page">
13163 <div class="page-header">
13164 <h1>How would you like to scan?</h1>
13165 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13166 </div>
13167
13168 <div class="option-grid">
13169
13170 <!-- Option 1: New scan -->
13171 <div class="option-card-wrap">
13172 <div class="option-card">
13173 <div class="option-icon new-scan">
13174 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13175 </div>
13176 <div class="card-body">
13177 <div class="card-left">
13178 <div class="card-text">
13179 <div class="option-title">Start a new scan</div>
13180 <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>
13181 <ul class="feature-list">
13182 <li>Live project scope preview before you run</li>
13183 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13184 <li>HTML, PDF, and JSON output — your choice</li>
13185 </ul>
13186 </div>
13187 </div>
13188 <div class="card-right">
13189 <a class="btn btn-primary" href="/scan">
13190 Configure & scan
13191 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13192 </a>
13193 <p class="card-tip">Full 4-step setup · all options</p>
13194 </div>
13195 </div>
13196 </div>
13197 </div>
13198
13199 <!-- Option 2: Load from config file -->
13200 <div class="option-card-wrap">
13201 <div class="option-card">
13202 <div class="option-icon load-config">
13203 <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>
13204 </div>
13205 <div class="card-body">
13206 <div class="card-left">
13207 <div class="card-text">
13208 <div class="option-title">Load a saved config</div>
13209 <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>
13210 <ul class="feature-list">
13211 <li>All 15 settings restored from the file</li>
13212 <li>Fully editable — change path or output dir</li>
13213 <li>Works with any scan-config.json</li>
13214 </ul>
13215 </div>
13216 </div>
13217 <div class="card-right">
13218 <div class="file-input-wrap">
13219 <button class="btn btn-secondary" id="load-config-btn" type="button">
13220 <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>
13221 Choose config file
13222 </button>
13223 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13224 </div>
13225 <p class="card-tip" id="config-file-name">Exported after every scan</p>
13226 </div>
13227 </div>
13228 </div>
13229 </div>
13230
13231 <!-- Option 3: Re-scan recent project -->
13232 <div class="option-card-wrap">
13233 <div class="option-card" id="recent-card">
13234 <div class="card-top-row">
13235 <div class="option-icon rescan">
13236 <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>
13237 </div>
13238 <div class="card-body">
13239 <div class="card-left">
13240 <div class="card-text">
13241 <div class="option-title">Re-scan a recent project</div>
13242 <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>
13243 <ul class="feature-list">
13244 <li>All 15+ settings restored from the saved config</li>
13245 <li>Path and output dir are editable before running</li>
13246 <li>Only scans with a saved config appear here</li>
13247 </ul>
13248 </div>
13249 </div>
13250 <div class="card-right">
13251 <div class="rescan-count-box">
13252 <div class="rescan-count-num" id="rescan-count-num">—</div>
13253 <div class="rescan-count-label">saved configs</div>
13254 </div>
13255 <a class="btn btn-secondary" href="/view-reports">
13256 <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>
13257 View all runs
13258 </a>
13259 <p class="card-tip">Opens run history</p>
13260 </div>
13261 </div>
13262 </div>
13263 <div class="section-divider"></div>
13264 <div class="recent-list" id="recent-list">
13265 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13266 </div>
13267 </div>
13268 </div>
13269
13270 </div>
13271 </div>
13272
13273 <footer class="site-footer">
13274 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
13275 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13276 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13277 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13278 · <a href="/api-docs" rel="noopener">REST API</a>
13279 </footer>
13280
13281 <script nonce="{{ csp_nonce }}">
13282 (function () {
13283 var storageKey = 'oxide-sloc-theme';
13284 var body = document.body;
13285 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13286 var toggle = document.getElementById('theme-toggle');
13287 if (toggle) toggle.addEventListener('click', function () {
13288 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13289 body.classList.toggle('dark-theme', next === 'dark');
13290 try { localStorage.setItem(storageKey, next); } catch(e) {}
13291 });
13292
13293 (function randomizeWatermarks() {
13294 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13295 if (!wms.length) return;
13296 var placed = [];
13297 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; }
13298 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]; }
13299 var half = Math.floor(wms.length / 2);
13300 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; });
13301 })();
13302 (function spawnCodeParticles() {
13303 var container = document.getElementById('code-particles');
13304 if (!container) return;
13305 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'];
13306 var count = 38;
13307 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); }
13308 })();
13309 // Recent scans data injected from server
13310 var recentScans = {{ recent_scans_json|safe }};
13311
13312 function configToParams(cfg) {
13313 var p = new URLSearchParams();
13314 p.set('prefilled', '1');
13315 if (cfg.path) p.set('path', cfg.path);
13316 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13317 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13318 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13319 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13320 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13321 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13322 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13323 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13324 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13325 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13326 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13327 if (cfg.report_title) p.set('report_title', cfg.report_title);
13328 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13329 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13330 return p;
13331 }
13332
13333 // Build recent scan list (capped at 3 visible entries)
13334 var list = document.getElementById('recent-list');
13335 var noNote = document.getElementById('no-recent-note');
13336 var hasAny = false;
13337 var MAX_RECENT = 3;
13338 if (Array.isArray(recentScans)) {
13339 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13340 var shown = 0;
13341 validEntries.forEach(function (entry) {
13342 if (shown >= MAX_RECENT) return;
13343 shown++;
13344 hasAny = true;
13345 var item = document.createElement('div');
13346 item.className = 'recent-item';
13347 item.title = 'Restore all settings and open wizard';
13348 item.innerHTML =
13349 '<div class="recent-item-info">' +
13350 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13351 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
13352 '</div>' +
13353 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13354 item.addEventListener('click', function () {
13355 var params = configToParams(entry.config);
13356 window.location.href = '/scan?' + params.toString();
13357 });
13358 list.appendChild(item);
13359 });
13360 if (validEntries.length > MAX_RECENT) {
13361 var moreEl = document.createElement('div');
13362 moreEl.className = 'recent-more-link';
13363 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
13364 list.appendChild(moreEl);
13365 }
13366 }
13367 if (hasAny && noNote) noNote.style.display = 'none';
13368 // Update count badge
13369 var countEl = document.getElementById('rescan-count-num');
13370 if (countEl) {
13371 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13372 countEl.textContent = total > 0 ? total : '0';
13373 }
13374
13375 // Config file loader
13376 var fileInput = document.getElementById('config-file-input');
13377 var fileName = document.getElementById('config-file-name');
13378 if (fileInput) {
13379 fileInput.addEventListener('change', function () {
13380 var file = fileInput.files && fileInput.files[0];
13381 if (!file) return;
13382 if (fileName) fileName.textContent = '✓ ' + file.name;
13383 var reader = new FileReader();
13384 reader.onload = function (e) {
13385 try {
13386 var cfg = JSON.parse(e.target.result);
13387 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13388 var params = configToParams(cfg);
13389 window.location.href = '/scan?' + params.toString();
13390 } catch (err) {
13391 alert('Could not parse config file: ' + err.message);
13392 }
13393 };
13394 reader.readAsText(file);
13395 });
13396 }
13397
13398 function escHtml(s) {
13399 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
13400 }
13401 })();
13402 </script>
13403 <script nonce="{{ csp_nonce }}">
13404 (function(){
13405 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'}];
13406 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);});}
13407 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13408 function init(){
13409 var btn=document.getElementById('settings-btn');if(!btn)return;
13410 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13411 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>';
13412 document.body.appendChild(m);
13413 var g=document.getElementById('scheme-grid');
13414 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);});
13415 var cl=document.getElementById('settings-close');
13416 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);
13417 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');});
13418 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13419 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13420 }
13421 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13422 }());
13423 </script>
13424</body>
13425</html>
13426"##,
13427 ext = "html"
13428)]
13429struct ScanSetupTemplate {
13430 version: &'static str,
13431 recent_scans_json: String,
13432 csp_nonce: String,
13433}
13434
13435#[derive(Template)]
13436#[template(
13437 source = r##"
13438<!doctype html>
13439<html lang="en">
13440<head>
13441 <meta charset="utf-8">
13442 <meta name="viewport" content="width=device-width, initial-scale=1">
13443 <title>OxideSLOC | {{ report_title }} | Report</title>
13444 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13445 <style nonce="{{ csp_nonce }}">
13446 :root {
13447 --radius: 18px;
13448 --bg: #f5efe8;
13449 --surface: rgba(255,255,255,0.82);
13450 --surface-2: #fbf7f2;
13451 --surface-3: #efe6dc;
13452 --line: #e6d0bf;
13453 --line-strong: #dcb89f;
13454 --text: #43342d;
13455 --muted: #7b675b;
13456 --muted-2: #a08777;
13457 --nav: #b85d33;
13458 --nav-2: #7a371b;
13459 --accent: #6f9bff;
13460 --accent-2: #4a78ee;
13461 --oxide: #d37a4c;
13462 --oxide-2: #b35428;
13463 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13464 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13465 --success-bg: #e8f5ed;
13466 --success-text: #1a8f47;
13467 --info-bg: #eef3ff;
13468 --info-text: #4467d8;
13469 }
13470
13471 body.dark-theme {
13472 --bg: #1b1511;
13473 --surface: #261c17;
13474 --surface-2: #2d221d;
13475 --surface-3: #372922;
13476 --line: #524238;
13477 --line-strong: #6c5649;
13478 --text: #f5ece6;
13479 --muted: #c7b7aa;
13480 --muted-2: #aa9485;
13481 --nav: #b85d33;
13482 --nav-2: #7a371b;
13483 --accent: #6f9bff;
13484 --accent-2: #4a78ee;
13485 --oxide: #d37a4c;
13486 --oxide-2: #b35428;
13487 --shadow: 0 18px 42px rgba(0,0,0,0.28);
13488 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13489 --success-bg: #163927;
13490 --success-text: #8fe2a8;
13491 --info-bg: #1c2847;
13492 --info-text: #a9c1ff;
13493 }
13494
13495 * { box-sizing: border-box; }
13496 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); }
13497 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13498 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13499 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13500 .top-nav, .page { position: relative; z-index: 2; }
13501 .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); }
13502 .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; }
13503 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13504 .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)); }
13505 .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; }
13506 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13507 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13508 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13509 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13510 .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; }
13511 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13512 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13513 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13514 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13515 @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; } }
13516 .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; }
13517 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13518 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13519 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13520 .theme-toggle .icon-sun { display:none; }
13521 body.dark-theme .theme-toggle .icon-sun { display:block; }
13522 body.dark-theme .theme-toggle .icon-moon { display:none; }
13523 .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;}
13524 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13525 .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);}
13526 .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;}
13527 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13528 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13529 .settings-modal-body{padding:14px 16px 16px;}
13530 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13531 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13532 .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;}
13533 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13534 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13535 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13536 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13537 .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;}
13538 .tz-select:focus{border-color:var(--oxide);}
13539 .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; }
13540 .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;}
13541 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13542 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13543 .hero, .panel { padding: 22px; }
13544 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13545 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13546 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13547 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13548 .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; }
13549 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13550 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13551 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13552 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13553 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13554 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13555 .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; }
13556 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13557 .delta-card-val { font-size:16px; font-weight:800; }
13558 .delta-card-val.pos { color:#1e7e34; }
13559 .delta-card-val.neg { color:var(--neg); }
13560 .delta-card-val.mod { color:#b35428; }
13561 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13562 .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; }
13563 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13564 .delta-card-inline:hover .delta-card-tip { opacity:1; }
13565 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13566 .compare-ts { font-size:13px; color:var(--muted); }
13567 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13568 .compare-arrow { color: var(--muted); }
13569 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13570 .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; }
13571 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13572 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13573 .button, .copy-button {
13574 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;
13575 }
13576 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13577 @keyframes spin { to { transform: rotate(360deg); } }
13578 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13579 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13580 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13581 .path-item strong { display: block; margin-bottom: 6px; }
13582 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13583 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13584 .path-subitem { flex: 1; }
13585 .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); }
13586 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); }
13587 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13588 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13589 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13590 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13591 th { color: var(--muted); font-weight: 700; }
13592 tr:last-child td { border-bottom: none; }
13593 #subm-tbl col:nth-child(1){width:15%;}
13594 #subm-tbl col:nth-child(2){width:31%;}
13595 #subm-tbl col:nth-child(3){width:9%;}
13596 #subm-tbl col:nth-child(4){width:9%;}
13597 #subm-tbl col:nth-child(5){width:9%;}
13598 #subm-tbl col:nth-child(6){width:9%;}
13599 #subm-tbl col:nth-child(7){width:9%;}
13600 #subm-tbl col:nth-child(8){width:9%;}
13601 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13602 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13603 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13604 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13605 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13606 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13607 .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; }
13608 .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; }
13609 .soft-chip.success svg { flex:0 0 auto; }
13610 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); }
13611 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13612 .muted { color: var(--muted); }
13613 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13614 .site-footer a{color:var(--muted);}
13615 .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; }
13616 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13617 .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; }
13618 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13619 /* Stat chips (matches HTML report) */
13620 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13621 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13622 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13623 .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; }
13624 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13625 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13626 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13627 .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; }
13628 .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; }
13629 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13630 .stat-chip:hover .stat-chip-tip { opacity:1; }
13631 /* Submodule panel */
13632 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13633 /* Metrics tables stack */
13634 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13635 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13636 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13637 .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)); }
13638 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13639 /* Metrics table */
13640 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13641 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13642 .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; }
13643 .metrics-table thead th:not(:first-child) { text-align: right; }
13644 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13645 .metrics-table tbody tr:last-child td { border-bottom: none; }
13646 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13647 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13648 .metrics-table tbody tr:hover td { background: var(--surface-2); }
13649 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13650 .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; }
13651 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13652 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13653 .mt-val-pos { color: var(--pos); font-weight: 700; }
13654 .mt-val-neg { color: var(--neg); font-weight: 700; }
13655 .mt-val-zero { color: var(--muted); }
13656 .mt-val-mod { color: var(--oxide-2); }
13657 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13658 @media (max-width: 1180px) {
13659 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13660 .nav-project-slot, .nav-status { justify-content:flex-start; }
13661 .hero-top { flex-direction: column; }
13662 }
13663 .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;}
13664 @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));}}
13665 .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;}
13666 /* ── Result-page chart controls ─────────────────────────────────────────── */
13667 .r-chart-section{margin-bottom:24px;}
13668 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13669 .section-pair > .panel{flex-shrink:0;}
13670 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13671 .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;}
13672 .r-chart-select:focus{border-color:var(--accent);}
13673 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13674 .r-chart-container svg{display:block;width:100%;height:auto;}
13675 .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13676 .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13677 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13678 .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;}
13679 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13680 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13681 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13682 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13683 #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;}
13684 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13685 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13686 .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;}
13687 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13688 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13689 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13690 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13691 .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%;}
13692 .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%;}
13693 body.has-report-banner .top-nav{top:27px;}
13694 body.has-report-banner{padding-bottom:27px;}
13695 </style>
13696</head>
13697<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13698 <div class="background-watermarks" aria-hidden="true">
13699 <img src="/images/logo/logo-text.png" alt="" />
13700 <img src="/images/logo/logo-text.png" alt="" />
13701 <img src="/images/logo/logo-text.png" alt="" />
13702 <img src="/images/logo/logo-text.png" alt="" />
13703 <img src="/images/logo/logo-text.png" alt="" />
13704 <img src="/images/logo/logo-text.png" alt="" />
13705 <img src="/images/logo/logo-text.png" alt="" />
13706 <img src="/images/logo/logo-text.png" alt="" />
13707 <img src="/images/logo/logo-text.png" alt="" />
13708 <img src="/images/logo/logo-text.png" alt="" />
13709 <img src="/images/logo/logo-text.png" alt="" />
13710 <img src="/images/logo/logo-text.png" alt="" />
13711 <img src="/images/logo/logo-text.png" alt="" />
13712 <img src="/images/logo/logo-text.png" alt="" />
13713 </div>
13714 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13715 {% if let Some(banner) = report_header_footer %}
13716 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13717 {% endif %}
13718 <div class="top-nav">
13719 <div class="top-nav-inner">
13720 <a class="brand" href="/">
13721 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13722 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13723 </a>
13724 <div class="nav-project-slot">
13725 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13726 </div>
13727 <div class="nav-status">
13728 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13729 <div class="nav-dropdown">
13730 <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>
13731 <div class="nav-dropdown-menu">
13732 <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>
13733 </div>
13734 </div>
13735 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13736 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13737 <div class="nav-dropdown">
13738 <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>
13739 <div class="nav-dropdown-menu">
13740 <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>
13741 </div>
13742 </div>
13743 <div class="server-status-wrap">
13744 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13745 <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>
13746 </div>
13747 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13748 <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>
13749 </button>
13750 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13751 <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>
13752 <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>
13753 </button>
13754 </div>
13755 </div>
13756 </div>
13757
13758 <div class="page">
13759 <section class="hero">
13760 <div class="hero-top">
13761 <div>
13762 <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>
13763 <h1 class="hero-title">{{ report_title }}</h1>
13764 <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>
13765 </div>
13766 <div class="hero-quick-actions">
13767 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13768 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13769 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13770 </div>
13771 </div>
13772
13773 <div class="summary-strip">
13774 <div class="stat-chip" data-raw="{{ physical_lines }}">
13775 <div class="stat-chip-label">Physical Lines</div>
13776 <div class="stat-chip-val">{{ physical_lines }}</div>
13777 <div class="stat-chip-exact"></div>
13778 <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13779 </div>
13780 <div class="stat-chip" data-raw="{{ code_lines }}">
13781 <div class="stat-chip-label">Code</div>
13782 <div class="stat-chip-val">{{ code_lines }}</div>
13783 <div class="stat-chip-exact"></div>
13784 <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13785 </div>
13786 <div class="stat-chip" data-raw="{{ comment_lines }}">
13787 <div class="stat-chip-label">Comments</div>
13788 <div class="stat-chip-val">{{ comment_lines }}</div>
13789 <div class="stat-chip-exact"></div>
13790 <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13791 </div>
13792 <div class="stat-chip" data-raw="{{ blank_lines }}">
13793 <div class="stat-chip-label">Blank</div>
13794 <div class="stat-chip-val">{{ blank_lines }}</div>
13795 <div class="stat-chip-exact"></div>
13796 <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13797 </div>
13798 <div class="stat-chip" data-raw="{{ files_analyzed }}">
13799 <div class="stat-chip-label">Files Analyzed</div>
13800 <div class="stat-chip-val">{{ files_analyzed }}</div>
13801 <div class="stat-chip-exact"></div>
13802 <div class="stat-chip-tip">Source files successfully parsed and counted</div>
13803 </div>
13804 <div class="stat-chip" data-raw="{{ functions }}">
13805 <div class="stat-chip-label">Functions</div>
13806 <div class="stat-chip-val">{{ functions }}</div>
13807 <div class="stat-chip-exact"></div>
13808 <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
13809 </div>
13810 </div>
13811
13812 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
13813 <div class="compare-banner">
13814 <div class="compare-banner-body">
13815 <div class="compare-banner-meta">
13816 <span class="compare-label">Previous scan</span>
13817 <span class="compare-ts">{{ prev_ts }}</span>
13818 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
13819 {% if let Some(prev_code) = prev_run_code_lines %}
13820 <div class="compare-banner-stats" style="margin-top:4px;">
13821 <span>Code before: <strong>{{ prev_code }}</strong></span>
13822 <span class="compare-arrow">→</span>
13823 <span>Code now: <strong>{{ code_lines }}</strong></span>
13824 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
13825 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
13826 </div>
13827 {% endif %}
13828 </div>
13829 {% if delta_lines_added.is_some() %}
13830 <div class="delta-cards-inline">
13831 <div class="delta-card-inline">
13832 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
13833 <div class="delta-card-lbl">lines added</div>
13834 <div class="delta-card-tip">Code lines added since the previous scan</div>
13835 </div>
13836 <div class="delta-card-inline">
13837 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
13838 <div class="delta-card-lbl">lines removed</div>
13839 <div class="delta-card-tip">Code lines removed since the previous scan</div>
13840 </div>
13841 <div class="delta-card-inline">
13842 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
13843 <div class="delta-card-lbl">unmodified lines</div>
13844 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
13845 </div>
13846 <div class="delta-card-inline">
13847 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
13848 <div class="delta-card-lbl">files modified</div>
13849 <div class="delta-card-tip">Files with at least one line changed</div>
13850 </div>
13851 <div class="delta-card-inline">
13852 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
13853 <div class="delta-card-lbl">files added</div>
13854 <div class="delta-card-tip">New files added since the previous scan</div>
13855 </div>
13856 <div class="delta-card-inline">
13857 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
13858 <div class="delta-card-lbl">files removed</div>
13859 <div class="delta-card-tip">Files deleted since the previous scan</div>
13860 </div>
13861 <div class="delta-card-inline">
13862 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
13863 <div class="delta-card-lbl">files unchanged</div>
13864 <div class="delta-card-tip">Files with no changes since the previous scan</div>
13865 </div>
13866 </div>
13867 {% else %}
13868 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
13869 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
13870 </p>
13871 {% endif %}
13872 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
13873 </div>
13874 </div>
13875 {% endif %}{% endif %}
13876
13877 <div class="action-grid">
13878 <div class="action-card">
13879 <h3>HTML report</h3>
13880 <div class="action-buttons">
13881 {% match html_url %}
13882 {% when Some with (url) %}
13883 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
13884 {% when None %}{% endmatch %}
13885 {% match html_download_url %}
13886 {% when Some with (url) %}
13887 <a class="button secondary" href="{{ url }}">Download HTML</a>
13888 {% when None %}{% endmatch %}
13889 {% match html_path %}
13890 {% when Some with (_path) %}{% when None %}{% endmatch %}
13891 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
13892 </div>
13893 </div>
13894 <div class="action-card">
13895 <h3>PDF report</h3>
13896 <div class="action-buttons">
13897 {% match pdf_url %}
13898 {% when Some with (url) %}
13899 {% if pdf_generating %}
13900 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
13901 <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>
13902 Generating PDF…
13903 </button>
13904 {% else %}
13905 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
13906 {% endif %}
13907 {% when None %}{% endmatch %}
13908 {% match pdf_download_url %}
13909 {% when Some with (url) %}
13910 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
13911 {% when None %}{% endmatch %}
13912 {% match pdf_path %}
13913 {% when Some with (_path) %}{% when None %}{% endmatch %}
13914 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
13915 </div>
13916 </div>
13917 <div class="action-card">
13918 <h3>JSON result</h3>
13919 <div class="action-buttons">
13920 {% match json_url %}
13921 {% when Some with (url) %}
13922 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
13923 {% when None %}{% endmatch %}
13924 {% match json_download_url %}
13925 {% when Some with (url) %}
13926 <a class="button secondary" href="{{ url }}">Download JSON</a>
13927 {% when None %}{% endmatch %}
13928 {% match json_path %}
13929 {% when Some with (_path) %}
13930 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
13931 {% when None %}
13932 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
13933 {% endmatch %}
13934 </div>
13935 </div>
13936 <div class="action-card">
13937 <h3>Scan config</h3>
13938 <div class="action-buttons">
13939 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
13940 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
13941 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
13942 </div>
13943 </div>
13944 {% if confluence_configured %}
13945 <div class="action-card" id="confluenceCard">
13946 <h3>Confluence</h3>
13947 <div class="action-buttons">
13948 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
13949 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
13950 </div>
13951 <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>
13952 </div>
13953 {% endif %}
13954 </div>
13955 {% if confluence_configured %}
13956 <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;">
13957 <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);">
13958 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
13959 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
13960 <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;">
13961 <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>
13962 <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;">
13963 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
13964 <div style="display:flex;gap:10px;justify-content:flex-end;">
13965 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
13966 <button class="button" id="confSubmitBtn" type="button">Post</button>
13967 </div>
13968 </div>
13969 </div>
13970 {% endif %}
13971 {% if !submodule_rows.is_empty() %}
13972 <div class="submodule-panel">
13973 <div class="toolbar-row">
13974 <div>
13975 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
13976 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
13977 </div>
13978 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
13979 </div>
13980 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
13981 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
13982 <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>
13983 <thead>
13984 <tr>
13985 <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>
13986 <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>
13987 <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>
13988 <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>
13989 <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>
13990 <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>
13991 <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>
13992 <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>
13993 </tr>
13994 </thead>
13995 <tbody>
13996 {% for row in submodule_rows %}
13997 <tr>
13998 <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>
13999 <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>
14000 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
14001 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
14002 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
14003 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
14004 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
14005 <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>
14006 </tr>
14007 {% endfor %}
14008 </tbody>
14009 </table>
14010 </div>
14011 </div>
14012 {% endif %}
14013
14014 <div class="metrics-tables-stack">
14015
14016 <div class="metrics-table-wrap">
14017 <div class="metrics-table-title">Files</div>
14018 <table class="metrics-table">
14019 <thead>
14020 <tr>
14021 <th>Metric</th>
14022 <th>This Run</th>
14023 <th>Previous</th>
14024 <th>Change</th>
14025 </tr>
14026 </thead>
14027 <tbody>
14028 <tr>
14029 <td>Files analyzed</td>
14030 <td class="mt-val-large">{{ files_analyzed }}</td>
14031 <td>{{ prev_fa_str }}</td>
14032 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14033 </tr>
14034 <tr>
14035 <td>Files skipped</td>
14036 <td>{{ files_skipped }}</td>
14037 <td>{{ prev_fs_str }}</td>
14038 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14039 </tr>
14040 <tr>
14041 <td>Files modified</td>
14042 <td class="mt-val-na">—</td>
14043 <td class="mt-val-na">—</td>
14044 <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>
14045 </tr>
14046 <tr>
14047 <td>Files unchanged</td>
14048 <td class="mt-val-na">—</td>
14049 <td class="mt-val-na">—</td>
14050 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14051 </tr>
14052 </tbody>
14053 </table>
14054 </div>
14055
14056 <div class="metrics-table-wrap">
14057 <div class="metrics-table-title">Line Counts</div>
14058 <table class="metrics-table">
14059 <thead>
14060 <tr>
14061 <th>Metric</th>
14062 <th>This Run</th>
14063 <th>Previous</th>
14064 <th>Change</th>
14065 </tr>
14066 </thead>
14067 <tbody>
14068 <tr>
14069 <td>Physical lines</td>
14070 <td class="mt-val-large">{{ physical_lines }}</td>
14071 <td>{{ prev_pl_str }}</td>
14072 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14073 </tr>
14074 <tr>
14075 <td>Code lines</td>
14076 <td class="mt-val-large">{{ code_lines }}</td>
14077 <td>{{ prev_cl_str }}</td>
14078 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14079 </tr>
14080 <tr>
14081 <td>Comment lines</td>
14082 <td>{{ comment_lines }}</td>
14083 <td>{{ prev_cml_str }}</td>
14084 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14085 </tr>
14086 <tr>
14087 <td>Blank lines</td>
14088 <td>{{ blank_lines }}</td>
14089 <td>{{ prev_bl_str }}</td>
14090 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14091 </tr>
14092 <tr>
14093 <td>Mixed (separate)</td>
14094 <td>{{ mixed_lines }}</td>
14095 <td class="mt-val-na">—</td>
14096 <td class="mt-val-na">—</td>
14097 </tr>
14098 </tbody>
14099 </table>
14100 </div>
14101
14102 <div class="metrics-tables-lower">
14103 <div class="metrics-table-wrap">
14104 <div class="metrics-table-title">Code Structure</div>
14105 <table class="metrics-table">
14106 <thead>
14107 <tr>
14108 <th>Metric</th>
14109 <th>This Run</th>
14110 </tr>
14111 </thead>
14112 <tbody>
14113 <tr>
14114 <td>Functions</td>
14115 <td>{{ functions }}</td>
14116 </tr>
14117 <tr>
14118 <td>Classes / Types</td>
14119 <td>{{ classes }}</td>
14120 </tr>
14121 <tr>
14122 <td>Variables</td>
14123 <td>{{ variables }}</td>
14124 </tr>
14125 <tr>
14126 <td>Imports</td>
14127 <td>{{ imports }}</td>
14128 </tr>
14129 </tbody>
14130 </table>
14131 </div>
14132
14133 <div class="metrics-table-wrap">
14134 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14135 <table class="metrics-table">
14136 <thead>
14137 <tr>
14138 <th>Metric</th>
14139 <th>Change</th>
14140 </tr>
14141 </thead>
14142 <tbody>
14143 <tr>
14144 <td>Lines added</td>
14145 <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>
14146 </tr>
14147 <tr>
14148 <td>Lines removed</td>
14149 <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>
14150 </tr>
14151 <tr>
14152 <td>Lines modified (net)</td>
14153 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14154 </tr>
14155 <tr>
14156 <td>Lines unmodified</td>
14157 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14158 </tr>
14159 </tbody>
14160 </table>
14161 </div>
14162 </div>
14163
14164 </div>
14165
14166 <div class="path-list">
14167 <div class="path-item">
14168 <div class="path-item-label">Project path</div>
14169 <code>{{ project_path }}</code>
14170 </div>
14171 <div class="path-item">
14172 <div class="path-item-label">Git branch</div>
14173 {% if let Some(branch) = git_branch %}
14174 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14175 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14176 {% else %}
14177 <code style="color:var(--muted)">—</code>
14178 {% endif %}
14179 </div>
14180 <div class="path-item">
14181 <div class="path-item-label">Output folder</div>
14182 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14183 </div>
14184 <div class="path-item">
14185 <div class="path-item-label">Run ID</div>
14186 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14187 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14188 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14189 </div>
14190 </div>
14191 </div>
14192 </section>
14193
14194 <div id="r-tt" aria-hidden="true"></div>
14195
14196 <div class="section-pair">
14197 <section class="panel">
14198 <div class="toolbar-row">
14199 <div>
14200 <h2>Language breakdown</h2>
14201 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14202 </div>
14203 </div>
14204 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14205 </section>
14206
14207 <section class="panel r-chart-section">
14208 <div class="toolbar-row" style="margin-bottom:16px;">
14209 <div>
14210 <h2>Visualizations</h2>
14211 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14212 </div>
14213 </div>
14214
14215 <div class="r-viz-grid">
14216 <div class="r-viz-card">
14217 <p class="r-viz-card-title">Language Composition</p>
14218 <div class="r-chart-tab-bar">
14219 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14220 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14221 </div>
14222 <div class="r-chart-container" id="r-composition-chart"></div>
14223 </div>
14224 <div class="r-viz-card">
14225 <p class="r-viz-card-title">Files vs Code Lines</p>
14226 <div class="r-chart-container" id="r-scatter-chart"></div>
14227 </div>
14228 {% if has_semantic_data %}
14229 <div class="r-viz-card">
14230 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14231 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14232 <select class="r-chart-select" id="r-semantic-metric">
14233 <option value="functions">Functions</option>
14234 <option value="classes">Classes</option>
14235 <option value="variables">Variables</option>
14236 <option value="imports">Imports</option>
14237 </select>
14238 </div>
14239 <div class="r-chart-container" id="r-semantic-chart"></div>
14240 </div>
14241 {% endif %}
14242 {% if has_submodule_data %}
14243 <div class="r-viz-card">
14244 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14245 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14246 <select class="r-chart-select" id="r-sub-metric">
14247 <option value="code">Code Lines</option>
14248 <option value="comment">Comments</option>
14249 <option value="blank">Blank Lines</option>
14250 <option value="physical">Physical Lines</option>
14251 <option value="files">Files</option>
14252 </select>
14253 <select class="r-chart-select" id="r-sub-sort">
14254 <option value="desc">Value ↓</option>
14255 <option value="asc">Value ↑</option>
14256 <option value="name">Name A→Z</option>
14257 </select>
14258 </div>
14259 <div class="r-chart-container" id="r-submodule-chart"></div>
14260 </div>
14261 {% endif %}
14262 </div>
14263
14264 </section>
14265 </div>
14266
14267 </div>
14268
14269 <script nonce="{{ csp_nonce }}">
14270 (function () {
14271 var body = document.body;
14272 var themeToggle = document.getElementById('theme-toggle');
14273 var storageKey = 'oxide-sloc-theme';
14274
14275 function applyTheme(theme) {
14276 body.classList.toggle('dark-theme', theme === 'dark');
14277 }
14278
14279 function loadSavedTheme() {
14280 try {
14281 var saved = localStorage.getItem(storageKey);
14282 if (saved === 'dark' || saved === 'light') {
14283 applyTheme(saved);
14284 }
14285 } catch (e) {}
14286 }
14287
14288 if (themeToggle) {
14289 themeToggle.addEventListener('click', function () {
14290 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14291 applyTheme(nextTheme);
14292 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14293 });
14294 }
14295
14296 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14297 button.addEventListener('click', function () {
14298 var value = button.getAttribute('data-copy-value') || '';
14299 if (!value) return;
14300 if (navigator.clipboard && navigator.clipboard.writeText) {
14301 navigator.clipboard.writeText(value).catch(function () {});
14302 }
14303 });
14304 });
14305
14306 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14307 btn.addEventListener('click', function () {
14308 var folder = btn.getAttribute('data-folder') || '';
14309 if (!folder) return;
14310 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14311 });
14312 });
14313
14314 loadSavedTheme();
14315
14316 // ── Compact number formatting for stat chips ──────────────────────────
14317 (function(){
14318 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();}
14319 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14320 var raw=parseInt(chip.getAttribute('data-raw'),10);
14321 if(isNaN(raw))return;
14322 var valEl=chip.querySelector('.stat-chip-val');
14323 if(valEl)valEl.textContent=fmt(raw);
14324 var exactEl=chip.querySelector('.stat-chip-exact');
14325 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14326 });
14327 })();
14328
14329 // ── Shared tooltip for all result-page charts ─────────────────────────
14330 var rTT=(function(){
14331 var el=document.getElementById('r-tt');
14332 if(!el)return{s:function(){},h:function(){},m:function(){}};
14333 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14334 function hide(){el.style.display='none';}
14335 function move(e){
14336 var x=e.clientX+16,y=e.clientY-12;
14337 var r=el.getBoundingClientRect();
14338 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14339 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14340 el.style.left=x+'px';el.style.top=y+'px';
14341 }
14342 return{s:show,h:hide,m:move};
14343 })();
14344 window.rTT=rTT;
14345
14346 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14347 (function(){
14348 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14349 document.addEventListener('mouseover',function(e){
14350 var t=e.target;
14351 while(t&&t.getAttribute){
14352 var l=t.getAttribute('data-ttl');
14353 if(l!==null){
14354 var v=t.getAttribute('data-ttv')||'';
14355 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14356 return;
14357 }
14358 t=t.parentNode;
14359 }
14360 });
14361 document.addEventListener('mouseout',function(e){
14362 var t=e.target;
14363 while(t&&t.getAttribute){
14364 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14365 t=t.parentNode;
14366 }
14367 });
14368 document.addEventListener('mousemove',function(e){
14369 var el=document.getElementById('r-tt');
14370 if(el&&el.style.display!=='none')rTT.m(e);
14371 });
14372 })();
14373
14374 // ── Language overview charts ───────────────────────────────────────────
14375 (function(){
14376 var D={{ lang_chart_json|safe }};
14377 if(!D||!D.length)return;
14378 var el=document.getElementById('result-lang-charts');
14379 if(!el)return;
14380 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14381 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14382 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14383 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();}
14384 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14385 function px(n){return Math.round(n);}
14386 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+'"';}
14387 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14388
14389 // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14390 var cx=100,cy=110,Ro=88,Ri=48;
14391 var legX=204,DW=360,DH=220;
14392 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">';
14393 if(D.length===1){
14394 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14395 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+'"/>';
14396 } else {
14397 var ang=-Math.PI/2;
14398 D.forEach(function(d,i){
14399 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14400 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14401 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14402 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14403 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14404 var pct=Math.round(d.code/tot*100);
14405 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"/>';
14406 ang+=sw;
14407 });
14408 }
14409 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14410 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14411 var legRows=Math.min(D.length,8);
14412 var legYStart=Math.round((DH-legRows*22)/2);
14413 D.forEach(function(d,i){
14414 if(i>=8)return;
14415 var ly=legYStart+i*22;
14416 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14417 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14418 });
14419 ds+='</svg>';
14420
14421 // Horizontal stacked-bar chart — fills container width
14422 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14423 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14424 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">';
14425 D.forEach(function(d,i){
14426 var y=6+i*rHb,x=LW;
14427 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14428 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>';
14429 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;
14430 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;
14431 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"/>';
14432 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>';
14433 });
14434 var ly=SH-14;
14435 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>';
14436 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>';
14437 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>';
14438 bs+='</svg>';
14439 el.innerHTML='<div class="r-lang-overview">'+
14440 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14441 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14442 '</div>';
14443 })();
14444
14445 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14446 (function(){
14447 var LANG_D={{ lang_chart_json|safe }};
14448 var SCAT_D={{ scatter_chart_json|safe }};
14449 var SEM_D={{ semantic_chart_json|safe }};
14450 var SUB_D={{ submodule_chart_json|safe }};
14451 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14452 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14453 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();}
14454 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14455 function px(n){return Math.round(n);}
14456 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+'"';}
14457
14458 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14459 function renderComposition(mode){
14460 var el=document.getElementById('r-composition-chart');
14461 if(!el||!LANG_D||!LANG_D.length)return;
14462 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14463 var LW=110,SH=224;
14464 var svgW=Math.max(320,el.offsetWidth||480);
14465 var BW=Math.max(120,svgW-LW-80);
14466 var legendH=24,topPad=4;
14467 var n=LANG_D.length||1;
14468 var rowTotal=Math.floor((SH-legendH-topPad)/n);
14469 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14470 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">';
14471 if(mode==='pct'){
14472 LANG_D.forEach(function(d,i){
14473 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14474 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14475 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14476 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>';
14477 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;
14478 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;
14479 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+'"/>';
14480 var pct=Math.round((d.code||0)/tot2*100);
14481 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14482 });
14483 } else {
14484 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14485 LANG_D.forEach(function(d,i){
14486 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14487 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14488 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>';
14489 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;
14490 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;
14491 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+'"/>';
14492 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>';
14493 });
14494 }
14495 var ly=SH-legendH+4;
14496 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>';
14497 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>';
14498 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>';
14499 s+='</svg>';
14500 el.innerHTML=s;
14501 }
14502 renderComposition('abs');
14503 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14504 btn.addEventListener('click',function(){
14505 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14506 btn.classList.add('active');
14507 renderComposition(btn.getAttribute('data-rcomp'));
14508 });
14509 });
14510
14511 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14512 (function(){
14513 var el=document.getElementById('r-scatter-chart');
14514 if(!el||!SCAT_D||!SCAT_D.length)return;
14515 var H=224,PL=52,PB=36,PT=12,PR=14;
14516 var W=Math.max(320,el.offsetWidth||480);
14517 var cW=W-PL-PR,cH=H-PT-PB;
14518 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14519 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14520 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14521 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">';
14522 [0,0.25,0.5,0.75,1].forEach(function(t){
14523 var y=PT+cH*(1-t);
14524 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14525 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>';
14526 });
14527 [0,0.25,0.5,0.75,1].forEach(function(t){
14528 var x=PL+cW*t;
14529 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14530 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>';
14531 });
14532 SCAT_D.forEach(function(d,i){
14533 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14534 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14535 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"/>';
14536 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>';
14537 });
14538 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>';
14539 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>';
14540 s+='</svg>';
14541 el.innerHTML=s;
14542 })();
14543
14544 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14545 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14546 // the old vertical column layout on wide containers.
14547 function renderSemantic(key){
14548 var el=document.getElementById('r-semantic-chart');
14549 if(!el||!SEM_D||!SEM_D.length)return;
14550 var LW=112,SH=224;
14551 var svgW=Math.max(320,el.offsetWidth||480);
14552 var BW=Math.max(120,svgW-LW-80);
14553 var topPad=4,botPad=14;
14554 var n2=SEM_D.length||1;
14555 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14556 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14557 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14558 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">';
14559 SEM_D.forEach(function(d,i){
14560 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14561 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>';
14562 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"/>';
14563 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>';
14564 });
14565 s+='</svg>';
14566 el.innerHTML=s;
14567 }
14568 var semSel=document.getElementById('r-semantic-metric');
14569 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14570
14571 // ── Submodule: horizontal bar chart ────────────────────────────────────
14572 function renderSubmodule(key,sort){
14573 var el=document.getElementById('r-submodule-chart');
14574 if(!el||!SUB_D||!SUB_D.length)return;
14575 var data=SUB_D.slice();
14576 if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14577 else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14578 else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14579 var LW=128,SH=224;
14580 var svgW=Math.max(320,el.offsetWidth||480);
14581 var BW=Math.max(120,svgW-LW-80);
14582 var topPad3=4,botPad3=14;
14583 var n3=data.length||1;
14584 var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14585 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14586 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14587 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">';
14588 data.forEach(function(d,i){
14589 var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14590 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>';
14591 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"/>';
14592 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>';
14593 });
14594 s+='</svg>';
14595 el.innerHTML=s;
14596 }
14597 var subSel=document.getElementById('r-sub-metric');
14598 var sortSel=document.getElementById('r-sub-sort');
14599 if(subSel){
14600 renderSubmodule('code','desc');
14601 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14602 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14603 }
14604
14605 // Re-render all SVG charts when the window is resized so bars fill the card.
14606 var _rResizeTimer;
14607 window.addEventListener('resize',function(){
14608 clearTimeout(_rResizeTimer);
14609 _rResizeTimer=setTimeout(function(){
14610 var rcompBtn=document.querySelector('[data-rcomp].active');
14611 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14612 (function(){
14613 var scEl=document.getElementById('r-scatter-chart');
14614 if(!scEl||!SCAT_D||!SCAT_D.length)return;
14615 var H=224,PL=52,PB=36,PT=12,PR=14;
14616 var W=Math.max(320,scEl.offsetWidth||480);
14617 var cW=W-PL-PR,cH=H-PT-PB;
14618 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14619 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14620 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14621 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">';
14622 [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>';});
14623 [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>';});
14624 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>';});
14625 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>';
14626 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>';
14627 s+='</svg>';scEl.innerHTML=s;
14628 })();
14629 if(semSel)renderSemantic(semSel.value||'functions');
14630 if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14631 },120);
14632 });
14633 })();
14634
14635 (function randomizeWatermarks() {
14636 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14637 if (!wms.length) return;
14638 var placed = [];
14639 function tooClose(top, left) {
14640 for (var i = 0; i < placed.length; i++) {
14641 var dt = Math.abs(placed[i][0] - top);
14642 var dl = Math.abs(placed[i][1] - left);
14643 if (dt < 20 && dl < 18) return true;
14644 }
14645 return false;
14646 }
14647 function pick(leftBand) {
14648 for (var attempt = 0; attempt < 50; attempt++) {
14649 var top = Math.random() * 85 + 5;
14650 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14651 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14652 }
14653 var top = Math.random() * 85 + 5;
14654 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14655 placed.push([top, left]);
14656 return [top, left];
14657 }
14658 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14659 var half = Math.floor(wms.length / 2);
14660 wms.forEach(function (img, i) {
14661 var pos = pick(i < half);
14662 var size = Math.floor(Math.random() * 100 + 160);
14663 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14664 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14665 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;
14666 });
14667 })();
14668
14669 (function spawnCodeParticles() {
14670 var container = document.getElementById('code-particles');
14671 if (!container) return;
14672 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'];
14673 for (var i = 0; i < 38; i++) {
14674 (function(idx) {
14675 var el = document.createElement('span');
14676 el.className = 'code-particle';
14677 el.textContent = snippets[idx % snippets.length];
14678 var left = Math.random() * 94 + 2;
14679 var top = Math.random() * 88 + 6;
14680 var dur = (Math.random() * 10 + 9).toFixed(1);
14681 var delay = (Math.random() * 18).toFixed(1);
14682 var rot = (Math.random() * 26 - 13).toFixed(1);
14683 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14684 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';
14685 container.appendChild(el);
14686 })(i);
14687 }
14688 })();
14689
14690 {% if pdf_generating %}
14691 // Poll for PDF readiness and swap the disabled button to a live link once done.
14692 (function() {
14693 var openBtn = document.getElementById('pdf-open-btn');
14694 var dlBtn = document.getElementById('pdf-download-btn');
14695 function checkPdf() {
14696 fetch('/api/runs/{{ run_id }}/pdf-status')
14697 .then(function(r) { return r.json(); })
14698 .then(function(d) {
14699 if (d.ready) {
14700 if (openBtn) {
14701 var a = document.createElement('a');
14702 a.className = 'button';
14703 a.id = 'pdf-open-btn';
14704 a.href = '/runs/pdf/{{ run_id }}';
14705 a.target = '_blank';
14706 a.rel = 'noopener';
14707 a.textContent = 'Open PDF';
14708 openBtn.replaceWith(a);
14709 }
14710 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14711 } else {
14712 setTimeout(checkPdf, 3000);
14713 }
14714 })
14715 .catch(function() { setTimeout(checkPdf, 5000); });
14716 }
14717 setTimeout(checkPdf, 3000);
14718 })();
14719 {% endif %}
14720
14721 })();
14722 </script>
14723 <script nonce="{{ csp_nonce }}">
14724 (function(){
14725 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'}];
14726 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);});}
14727 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14728 function init(){
14729 var btn=document.getElementById('settings-btn');if(!btn)return;
14730 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14731 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>';
14732 document.body.appendChild(m);
14733 var g=document.getElementById('scheme-grid');
14734 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);});
14735 var cl=document.getElementById('settings-close');
14736 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);
14737 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');});
14738 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14739 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14740 }
14741 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14742 }());
14743 </script>
14744 <footer class="site-footer">
14745 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
14746 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14747 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14748 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14749 · <a href="/api-docs" rel="noopener">REST API</a>
14750 </footer>
14751 {% if confluence_configured %}
14752 <script nonce="{{ csp_nonce }}">
14753 (function() {
14754 var postBtn = document.getElementById('postConfluenceBtn');
14755 var copyBtn = document.getElementById('copyWikiBtn');
14756 var modal = document.getElementById('confluenceModal');
14757 if (!postBtn || !modal) return;
14758
14759 postBtn.addEventListener('click', function() {
14760 document.getElementById('confStatus').style.display = 'none';
14761 modal.style.display = 'flex';
14762 });
14763 document.getElementById('confCancelBtn').addEventListener('click', function() {
14764 modal.style.display = 'none';
14765 });
14766 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14767
14768 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14769 var btn = this;
14770 btn.disabled = true;
14771 var status = document.getElementById('confStatus');
14772 status.style.display = 'block';
14773 status.style.background = '#dbeafe';
14774 status.style.color = '#1e40af';
14775 status.textContent = 'Posting to Confluence…';
14776 var resp = await fetch('/api/confluence/post', {
14777 method: 'POST',
14778 headers: { 'Content-Type': 'application/json' },
14779 body: JSON.stringify({
14780 run_id: '{{ run_id }}',
14781 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14782 report_url: document.getElementById('confReportUrl').value.trim() || null
14783 })
14784 });
14785 var data = await resp.json();
14786 if (data.ok) {
14787 status.style.background = '#dcfce7'; status.style.color = '#166534';
14788 status.textContent = 'Posted! Page ID: ' + data.page_id;
14789 } else {
14790 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14791 status.textContent = 'Error: ' + (data.error || 'Unknown error');
14792 }
14793 btn.disabled = false;
14794 });
14795
14796 if (copyBtn) {
14797 copyBtn.addEventListener('click', async function() {
14798 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14799 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14800 var text = await resp.text();
14801 try {
14802 await navigator.clipboard.writeText(text);
14803 var orig = copyBtn.textContent;
14804 copyBtn.textContent = 'Copied!';
14805 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
14806 } catch(e) {
14807 alert('Clipboard write failed — check browser permissions.');
14808 }
14809 });
14810 }
14811 })();
14812 </script>
14813 {% endif %}
14814 {% if let Some(banner) = report_header_footer %}
14815 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
14816 {% endif %}
14817</body>
14818</html>
14819"##,
14820 ext = "html"
14821)]
14822#[allow(clippy::struct_excessive_bools)]
14824struct ResultTemplate {
14825 version: &'static str,
14826 report_title: String,
14827 project_path: String,
14828 output_dir: String,
14829 run_id: String,
14830 files_analyzed: u64,
14831 files_skipped: u64,
14832 physical_lines: u64,
14833 code_lines: u64,
14834 comment_lines: u64,
14835 blank_lines: u64,
14836 mixed_lines: u64,
14837 functions: u64,
14838 classes: u64,
14839 variables: u64,
14840 imports: u64,
14841 html_url: Option<String>,
14842 pdf_url: Option<String>,
14843 json_url: Option<String>,
14844 html_download_url: Option<String>,
14845 pdf_download_url: Option<String>,
14846 json_download_url: Option<String>,
14847 html_path: Option<String>,
14848 pdf_path: Option<String>,
14849 json_path: Option<String>,
14850 prev_run_id: Option<String>,
14851 prev_run_timestamp: Option<String>,
14852 prev_run_code_lines: Option<u64>,
14853 prev_fa_str: String,
14855 prev_fs_str: String,
14856 prev_pl_str: String,
14857 prev_cl_str: String,
14858 prev_cml_str: String,
14859 prev_bl_str: String,
14860 delta_fa_str: String,
14862 delta_fa_class: String,
14863 delta_fs_str: String,
14864 delta_fs_class: String,
14865 delta_pl_str: String,
14866 delta_pl_class: String,
14867 delta_cl_str: String,
14868 delta_cl_class: String,
14869 delta_cml_str: String,
14870 delta_cml_class: String,
14871 delta_bl_str: String,
14872 delta_bl_class: String,
14873 delta_lines_added: Option<i64>,
14875 delta_lines_removed: Option<i64>,
14876 delta_lines_net_str: String,
14877 delta_lines_net_class: String,
14878 delta_files_added: Option<usize>,
14879 delta_files_removed: Option<usize>,
14880 delta_files_modified: Option<usize>,
14881 delta_files_unchanged: Option<usize>,
14882 delta_unmodified_lines: Option<u64>,
14883 git_branch: Option<String>,
14885 git_commit: Option<String>,
14886 git_author: Option<String>,
14887 prev_scan_count: usize,
14889 current_scan_number: usize,
14890 submodule_rows: Vec<SubmoduleRow>,
14892 scan_config_url: String,
14893 lang_chart_json: String,
14894 #[allow(dead_code)]
14896 scatter_chart_json: String,
14897 #[allow(dead_code)]
14898 semantic_chart_json: String,
14899 #[allow(dead_code)]
14900 submodule_chart_json: String,
14901 #[allow(dead_code)]
14902 has_submodule_data: bool,
14903 #[allow(dead_code)]
14904 has_semantic_data: bool,
14905 pdf_generating: bool,
14906 csp_nonce: String,
14907 confluence_configured: bool,
14909 report_header_footer: Option<String>,
14911}
14912
14913#[derive(Template)]
14914#[template(
14915 source = r##"
14916<!doctype html>
14917<html lang="en">
14918<head>
14919 <meta charset="utf-8">
14920 <meta name="viewport" content="width=device-width, initial-scale=1">
14921 <title>OxideSLOC | Analyzing…</title>
14922 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14923 <style nonce="{{ csp_nonce }}">
14924 :root {
14925 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14926 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14927 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
14928 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14929 }
14930 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
14931 *{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);}
14932 .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);}
14933 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14934 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
14935 .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));}
14936 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14937 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14938 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14939 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14940 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14941 @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; } }
14942 .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;}
14943 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14944 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
14945 .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
14946 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
14947 .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;}
14948 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
14949 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
14950 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
14951 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
14952 .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;}
14953 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
14954 .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;}
14955 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
14956 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
14957 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
14958 .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;}
14959 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
14960 .hidden{display:none!important;}
14961 .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;}
14962 .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;}
14963 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
14964 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
14965 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
14966 .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);}
14967 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
14968 .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;}
14969 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
14970 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14971 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14972 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
14973 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14974 .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;}
14975 @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));}}
14976 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14977 .site-footer a{color:var(--muted);}
14978 .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;}
14979 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
14980 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
14981 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
14982 </style>
14983</head>
14984<body>
14985 <div class="background-watermarks" aria-hidden="true">
14986 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14987 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14988 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14989 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14990 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14991 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14992 </div>
14993 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14994 <nav class="top-nav">
14995 <div class="top-nav-inner">
14996 <a href="/" class="brand">
14997 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
14998 <div class="brand-copy">
14999 <h1 class="brand-title">OxideSLOC</h1>
15000 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15001 </div>
15002 </a>
15003 <div class="nav-right">
15004 <a class="nav-pill" href="/">Home</a>
15005 <div class="nav-dropdown">
15006 <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>
15007 <div class="nav-dropdown-menu">
15008 <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>
15009 </div>
15010 </div>
15011 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15012 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15013 <div class="nav-dropdown">
15014 <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>
15015 <div class="nav-dropdown-menu">
15016 <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>
15017 </div>
15018 </div>
15019 <div class="server-status-wrap">
15020 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15021 <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>
15022 </div>
15023 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15024 <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>
15025 </button>
15026 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15027 <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>
15028 <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>
15029 </button>
15030 </div>
15031 </div>
15032 </nav>
15033 <div class="page-body">
15034 <div class="wait-panel">
15035 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15036 <h2 class="wait-title">Analyzing your project…</h2>
15037 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15038 <div class="path-block">{{ project_path }}</div>
15039 <div class="metrics-row">
15040 <div class="metric-card">
15041 <div class="metric-label">Elapsed</div>
15042 <div class="metric-value" id="elapsed">0s</div>
15043 </div>
15044 <div class="metric-card">
15045 <div class="metric-label">Phase</div>
15046 <div class="metric-value" id="phase">Starting</div>
15047 </div>
15048 </div>
15049 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15050 <div class="warn-slow hidden" id="warn-slow">
15051 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.
15052 </div>
15053 <div class="err-panel hidden" id="err-panel">
15054 <strong>Analysis failed</strong>
15055 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15056 </div>
15057 <div class="actions hidden" id="actions">
15058 <a href="/scan" class="btn-primary">Try Again</a>
15059 <a href="/view-reports" class="btn-outline">View Reports</a>
15060 </div>
15061 </div>
15062 </div>
15063 <script nonce="{{ csp_nonce }}">
15064 (function() {
15065 var WAIT_ID = {{ wait_id_json|safe }};
15066 var startTime = Date.now();
15067 var pollInterval = 1500;
15068 var retries = 0;
15069 var maxRetries = 5;
15070 var warnShown = false;
15071
15072 function elapsed() {
15073 return Math.floor((Date.now() - startTime) / 1000);
15074 }
15075
15076 function updateElapsed() {
15077 var s = elapsed();
15078 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15079 }
15080
15081 function setPhase(txt) {
15082 document.getElementById('phase').textContent = txt;
15083 }
15084
15085 var elapsedTimer = setInterval(updateElapsed, 1000);
15086
15087 function poll() {
15088 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15089 .then(function(r) {
15090 if (!r.ok) throw new Error('HTTP ' + r.status);
15091 return r.json();
15092 })
15093 .then(function(data) {
15094 retries = 0;
15095 if (data.state === 'complete') {
15096 clearInterval(elapsedTimer);
15097 setPhase('Done');
15098 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15099 } else if (data.state === 'failed') {
15100 clearInterval(elapsedTimer);
15101 setPhase('Failed');
15102 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15103 document.getElementById('err-panel').classList.remove('hidden');
15104 document.getElementById('actions').classList.remove('hidden');
15105 } else {
15106 // still running
15107 var s = elapsed();
15108 if (s > 90 && !warnShown) {
15109 warnShown = true;
15110 document.getElementById('warn-slow').classList.remove('hidden');
15111 }
15112 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15113 setTimeout(poll, pollInterval);
15114 }
15115 })
15116 .catch(function(err) {
15117 retries++;
15118 if (retries >= maxRetries) {
15119 clearInterval(elapsedTimer);
15120 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15121 document.getElementById('err-panel').classList.remove('hidden');
15122 document.getElementById('actions').classList.remove('hidden');
15123 } else {
15124 // exponential back-off capped at 8s
15125 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15126 }
15127 });
15128 }
15129
15130 setTimeout(poll, pollInterval);
15131 })();
15132 </script>
15133 <footer class="site-footer">
15134 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
15135 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15136 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15137 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15138 · <a href="/api-docs" rel="noopener">REST API</a>
15139 </footer>
15140 <script nonce="{{ csp_nonce }}">
15141 (function(){
15142 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15143 if(s==="dark")b.classList.add("dark-theme");
15144 var tt=document.getElementById("theme-toggle");
15145 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15146 })();
15147 (function spawnCodeParticles(){
15148 var c=document.getElementById('code-particles');if(!c)return;
15149 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'];
15150 for(var i=0;i<32;i++){(function(idx){
15151 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15152 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15153 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15154 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15155 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15156 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15157 c.appendChild(el);
15158 })(i);}
15159 })();
15160 (function randomizeWatermarks(){
15161 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15162 var placed=[];
15163 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;}
15164 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];}
15165 var half=Math.floor(wms.length/2);
15166 wms.forEach(function(img,i){
15167 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15168 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15169 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15170 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15171 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15172 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15173 });
15174 })();
15175 </script>
15176 <script nonce="{{ csp_nonce }}">
15177 (function(){
15178 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'}];
15179 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);});}
15180 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15181 function init(){
15182 var btn=document.getElementById('settings-btn');if(!btn)return;
15183 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15184 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>';
15185 document.body.appendChild(m);
15186 var g=document.getElementById('scheme-grid');
15187 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);});
15188 var cl=document.getElementById('settings-close');
15189 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);
15190 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');});
15191 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15192 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15193 }
15194 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15195 }());
15196 </script>
15197</body>
15198</html>
15199"##,
15200 ext = "html"
15201)]
15202struct ScanWaitTemplate {
15203 version: &'static str,
15204 wait_id_json: String,
15205 project_path: String,
15206 csp_nonce: String,
15207}
15208
15209#[derive(Template)]
15210#[template(
15211 source = r##"
15212<!doctype html>
15213<html lang="en">
15214<head>
15215 <meta charset="utf-8">
15216 <meta name="viewport" content="width=device-width, initial-scale=1">
15217 <title>OxideSLOC | Error</title>
15218 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15219 <style nonce="{{ csp_nonce }}">
15220 :root {
15221 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15222 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15223 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15224 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15225 }
15226 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15227 *{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);}
15228 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15229 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15230 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15231 .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);}
15232 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15233 .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));}
15234 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15235 .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;}
15236 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15237 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15238 @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; } }
15239 .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;}
15240 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15241 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15242 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15243 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15244 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15245 .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;}
15246 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15247 .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);}
15248 .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;}
15249 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15250 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15251 .settings-modal-body{padding:14px 16px 16px;}
15252 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15253 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15254 .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;}
15255 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15256 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15257 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15258 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15259 .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;}
15260 .tz-select:focus{border-color:var(--oxide);}
15261 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15262 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15263 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15264 .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;}
15265 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15266 .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);}
15267 .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;}
15268 .btn-secondary:hover{background:var(--line);}
15269 .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;}
15270 .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;}
15271 .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;}
15272 @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));}}
15273 .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;}
15274 </style>
15275</head>
15276<body>
15277 <div class="background-watermarks" aria-hidden="true">
15278 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15279 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15280 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15281 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15282 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15283 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15284 </div>
15285 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15286 <div class="top-nav">
15287 <div class="top-nav-inner">
15288 <a class="brand" href="/">
15289 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15290 <div class="brand-copy">
15291 <div class="brand-title">OxideSLOC</div>
15292 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15293 </div>
15294 </a>
15295 <div class="nav-right">
15296 <a class="nav-pill" href="/">Home</a>
15297 <div class="nav-dropdown">
15298 <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>
15299 <div class="nav-dropdown-menu">
15300 <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>
15301 </div>
15302 </div>
15303 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15304 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15305 <div class="nav-dropdown">
15306 <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>
15307 <div class="nav-dropdown-menu">
15308 <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>
15309 </div>
15310 </div>
15311 <div class="server-status-wrap">
15312 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15313 <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>
15314 </div>
15315 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15316 <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>
15317 </button>
15318 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15319 <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>
15320 <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>
15321 </button>
15322 </div>
15323 </div>
15324 </div>
15325
15326 <div class="page">
15327 <div class="panel">
15328 <h1>Error</h1>
15329 <div class="error-box">{{ message }}</div>
15330 <div class="actions">
15331 <a class="btn-primary" href="/scan">Back to setup</a>
15332 {% if let Some(report_url) = last_report_url %}
15333 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15334 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15335 {% else %}
15336 <a class="btn-secondary" href="/view-reports">View Reports</a>
15337 {% endif %}
15338 </div>
15339 </div>
15340 </div>
15341 <script nonce="{{ csp_nonce }}">
15342 (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");});})();
15343 (function spawnCodeParticles() {
15344 var container = document.getElementById('code-particles');
15345 if (!container) return;
15346 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'];
15347 for (var i = 0; i < 38; i++) {
15348 (function(idx) {
15349 var el = document.createElement('span');
15350 el.className = 'code-particle';
15351 el.textContent = snippets[idx % snippets.length];
15352 var left = Math.random() * 94 + 2;
15353 var top = Math.random() * 88 + 6;
15354 var dur = (Math.random() * 10 + 9).toFixed(1);
15355 var delay = (Math.random() * 18).toFixed(1);
15356 var rot = (Math.random() * 26 - 13).toFixed(1);
15357 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15358 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';
15359 container.appendChild(el);
15360 })(i);
15361 }
15362 })();
15363 (function randomizeWatermarks() {
15364 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15365 var placed = [];
15366 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; }
15367 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]; }
15368 var half = Math.floor(wms.length/2);
15369 wms.forEach(function(img, i) {
15370 var pos = pick(i < half);
15371 var w = Math.floor(Math.random()*60+80);
15372 var rot = (Math.random()*40-20).toFixed(1);
15373 var op = (Math.random()*0.08+0.05).toFixed(2);
15374 var animDur = (Math.random()*6+5).toFixed(1);
15375 var animDelay = (Math.random()*10).toFixed(1);
15376 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';
15377 });
15378 })();
15379 </script>
15380 <script nonce="{{ csp_nonce }}">
15381 (function(){
15382 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'}];
15383 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);});}
15384 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15385 function init(){
15386 var btn=document.getElementById('settings-btn');if(!btn)return;
15387 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15388 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>';
15389 document.body.appendChild(m);
15390 var g=document.getElementById('scheme-grid');
15391 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);});
15392 var cl=document.getElementById('settings-close');
15393 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);
15394 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');});
15395 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15396 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15397 }
15398 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15399 }());
15400 </script>
15401</body>
15402</html>
15403"##,
15404 ext = "html"
15405)]
15406struct ErrorTemplate {
15407 message: String,
15408 last_report_url: Option<String>,
15410 last_report_label: Option<String>,
15412 csp_nonce: String,
15413}
15414
15415#[derive(Template)]
15418#[template(
15419 source = r##"
15420<!doctype html>
15421<html lang="en">
15422<head>
15423 <meta charset="utf-8">
15424 <meta name="viewport" content="width=device-width, initial-scale=1">
15425 <title>OxideSLOC | Locate Scan Files</title>
15426 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15427 <style nonce="{{ csp_nonce }}">
15428 :root {
15429 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15430 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15431 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15432 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15433 }
15434 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15435 *{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);}
15436 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15437 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15438 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15439 .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);}
15440 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15441 .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));}
15442 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15443 .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;}
15444 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15445 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15446 @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;}}
15447 .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;}
15448 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15449 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15450 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15451 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15452 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15453 .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
15454 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15455 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
15456 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
15457 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15458 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15459 .settings-modal-body{padding:14px 16px 16px;}
15460 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15461 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15462 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
15463 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15464 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15465 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15466 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15467 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
15468 .tz-select:focus{border-color:var(--oxide);}
15469 .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15470 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15471 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15472 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15473 .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;}
15474 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15475 .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;}
15476 .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;}
15477 .btn-secondary:hover{background:var(--line);}
15478 .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;}
15479 .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;}
15480 .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;}
15481 @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));}}
15482 .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;}
15483 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15484 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15485 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15486 .relocate-row{display:flex;gap:8px;align-items:stretch;}
15487 .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;}
15488 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15489 body.dark-theme .relocate-input{background:var(--surface-2);}
15490 </style>
15491</head>
15492<body>
15493 <div class="background-watermarks" aria-hidden="true">
15494 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15495 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15496 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15497 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15498 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15499 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15500 </div>
15501 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15502 <div class="top-nav">
15503 <div class="top-nav-inner">
15504 <a class="brand" href="/">
15505 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15506 <div class="brand-copy">
15507 <div class="brand-title">OxideSLOC</div>
15508 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15509 </div>
15510 </a>
15511 <div class="nav-right">
15512 <a class="nav-pill" href="/">Home</a>
15513 <div class="nav-dropdown">
15514 <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>
15515 <div class="nav-dropdown-menu">
15516 <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>
15517 </div>
15518 </div>
15519 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15520 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15521 <div class="nav-dropdown">
15522 <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>
15523 <div class="nav-dropdown-menu">
15524 <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>
15525 </div>
15526 </div>
15527 <div class="server-status-wrap">
15528 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15529 <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>
15530 </div>
15531 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15532 <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>
15533 </button>
15534 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15535 <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>
15536 <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>
15537 </button>
15538 </div>
15539 </div>
15540 </div>
15541
15542 <div class="page">
15543 <div class="panel">
15544 <h1>Scan Files Moved</h1>
15545 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15546 <div class="error-box">{{ message }}</div>
15547 <div class="relocate-section">
15548 <h2>Locate Scan Output</h2>
15549 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15550 <form method="post" action="/relocate-scan">
15551 <input type="hidden" name="run_id" value="{{ run_id }}">
15552 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15553 <div class="relocate-row">
15554 <input type="text" id="relocate-folder" name="folder_path"
15555 value="{{ folder_hint }}"
15556 placeholder="Path to folder containing scan output..."
15557 class="relocate-input" autocomplete="off" spellcheck="false">
15558 {% if !server_mode %}
15559 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
15560 {% endif %}
15561 </div>
15562 <div style="margin-top:12px;">
15563 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15564 </div>
15565 </form>
15566 </div>
15567 <div class="actions">
15568 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15569 <a class="btn-secondary" href="/view-reports">View Reports</a>
15570 </div>
15571 </div>
15572 </div>
15573 <script nonce="{{ csp_nonce }}">
15574 (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");});})();
15575 (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);}})();
15576 (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';});})();
15577 </script>
15578 <script nonce="{{ csp_nonce }}">
15579 (function(){
15580 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'}];
15581 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);});}
15582 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15583 function init(){
15584 var btn=document.getElementById('settings-btn');if(!btn)return;
15585 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15586 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>';
15587 document.body.appendChild(m);
15588 var g=document.getElementById('scheme-grid');
15589 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);});
15590 var cl=document.getElementById('settings-close');
15591 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);
15592 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');});
15593 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15594 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15595 }
15596 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15597 }());
15598 (function(){
15599 var btn=document.getElementById('browse-relocate-btn');
15600 if(!btn)return;
15601 btn.addEventListener('click',function(){
15602 btn.disabled=true;btn.textContent='...';
15603 var inp=document.getElementById('relocate-folder');
15604 var hint=inp?inp.value:'';
15605 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
15606 .then(function(r){return r.json();})
15607 .then(function(d){
15608 btn.disabled=false;btn.textContent='Browse…';
15609 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15610 })
15611 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15612 });
15613 }());
15614 </script>
15615</body>
15616</html>
15617"##,
15618 ext = "html"
15619)]
15620struct RelocateScanTemplate {
15621 message: String,
15622 run_id: String,
15623 folder_hint: String,
15624 redirect_url: String,
15625 server_mode: bool,
15626 csp_nonce: String,
15627}
15628
15629#[derive(Template)]
15632#[template(
15633 source = r##"
15634<!doctype html>
15635<html lang="en">
15636<head>
15637 <meta charset="utf-8">
15638 <meta name="viewport" content="width=device-width, initial-scale=1">
15639 <title>OxideSLOC | View Reports</title>
15640 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15641 <style nonce="{{ csp_nonce }}">
15642 :root {
15643 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15644 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15645 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15646 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15647 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15648 }
15649 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; }
15650 *{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);}
15651 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15652 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15653 .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);}
15654 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15655 .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));}
15656 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15657 .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;}
15658 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15659 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15660 @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; } }
15661 .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;}
15662 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15663 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15664 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15665 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15666 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15667 .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;}
15668 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15669 .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);}
15670 .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;}
15671 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15672 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15673 .settings-modal-body{padding:14px 16px 16px;}
15674 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15675 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15676 .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;}
15677 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15678 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15679 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15680 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15681 .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;}
15682 .tz-select:focus{border-color:var(--oxide);}
15683 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15684 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15685 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15686 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15687 .panel-meta{font-size:13px;color:var(--muted);}
15688 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15689 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15690 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15691 .per-page-label{font-size:13px;color:var(--muted);}
15692 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;}
15693 .filter-input{min-width:180px;cursor:text;}
15694 .table-wrap{width:100%;overflow-x:auto;}
15695 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15696 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;}
15697 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15698 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15699 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15700 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15701 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15702 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15703 tr:last-child td{border-bottom:none;}
15704 tr:hover td{background:var(--surface-2);}
15705 .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);}
15706 .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);}
15707 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15708 .metric-num{font-weight:700;color:var(--text);}
15709 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15710 .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;}
15711 .btn:hover{background:var(--line);}
15712 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15713 .btn.primary:hover{opacity:.9;}
15714 .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;}
15715 .btn-back:hover{background:var(--line);}
15716 .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;}
15717 .export-btn:hover{background:var(--line);}
15718 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15719 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15720 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15721 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15722 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15723 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15724 .pagination-info{font-size:13px;color:var(--muted);}
15725 .pagination-btns{display:flex;gap:6px;}
15726 .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;}
15727 .pg-btn:hover:not(:disabled){background:var(--line);}
15728 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15729 .pg-btn:disabled{opacity:.35;cursor:default;}
15730 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15731 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15732 .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;}
15733 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15734 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15735 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15736 .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);}
15737 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15738 .stat-chip:hover .stat-chip-tip{opacity:1;}
15739 .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;}
15740 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15741 .site-footer a{color:var(--muted);}
15742 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15743 .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%;}
15744 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15745 .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;}
15746 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15747 .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;}
15748 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15749 .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;}
15750 .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;}
15751 .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;}
15752 @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));}}
15753 .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;}
15754 .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;}
15755 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15756 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15757 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15758 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15759 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15760 .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;}
15761 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15762 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15763 .watched-chip-rm:hover{color:var(--oxide);}
15764 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15765 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15766 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15767 .rpt-btn{min-width:58px;justify-content:center;}
15768 .flex-row{display:flex;align-items:center;gap:8px;}
15769 .report-cell{overflow:visible;white-space:normal;}
15770 #history-table col:nth-child(1){width:185px;}
15771 #history-table col:nth-child(2){width:220px;}
15772 #history-table col:nth-child(3){width:100px;}
15773 #history-table col:nth-child(4){width:72px;}
15774 #history-table col:nth-child(5){width:82px;}
15775 #history-table col:nth-child(6){width:82px;}
15776 #history-table col:nth-child(7){width:65px;}
15777 #history-table col:nth-child(8){width:90px;}
15778 #history-table col:nth-child(9){width:85px;}
15779 #history-table col:nth-child(10){width:115px;}
15780 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15781 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15782 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15783 .submod-details summary::-webkit-details-marker{display:none;}
15784.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15785 .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;}
15786 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15787 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15788 </style>
15789</head>
15790<body>
15791 <div class="background-watermarks" aria-hidden="true">
15792 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15793 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15794 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15795 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15796 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15797 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15798 </div>
15799 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15800 <div class="top-nav">
15801 <div class="top-nav-inner">
15802 <a class="brand" href="/">
15803 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15804 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
15805 </a>
15806 <div class="nav-right">
15807 <a class="nav-pill" href="/">Home</a>
15808 <div class="nav-dropdown">
15809 <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>
15810 <div class="nav-dropdown-menu">
15811 <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>
15812 </div>
15813 </div>
15814 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15815 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15816 <div class="nav-dropdown">
15817 <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>
15818 <div class="nav-dropdown-menu">
15819 <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>
15820 </div>
15821 </div>
15822 <div class="server-status-wrap">
15823 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15824 <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>
15825 </div>
15826 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15827 <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>
15828 </button>
15829 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15830 <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>
15831 <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>
15832 </button>
15833 </div>
15834 </div>
15835 </div>
15836
15837 <div class="page">
15838 {% if let Some(err) = browse_error %}
15839 <div class="toast-error">
15840 <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>
15841 {{ err }}
15842 </div>
15843 {% endif %}
15844 {% if linked_count > 0 %}
15845 <div class="toast-success">
15846 <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>
15847 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
15848 </div>
15849 {% endif %}
15850 <div class="watched-bar">
15851 <div class="watched-bar-left">
15852 <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>
15853 <span class="watched-label">Watched Folders</span>
15854 <div class="watched-chips">
15855 {% for dir in watched_dirs %}
15856 <span class="watched-chip">
15857 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
15858 <form method="POST" action="/watched-dirs/remove" style="display:contents">
15859 <input type="hidden" name="folder_path" value="{{ dir }}">
15860 <input type="hidden" name="redirect_to" value="/view-reports">
15861 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
15862 </form>
15863 </span>
15864 {% endfor %}
15865 {% if watched_dirs.is_empty() %}
15866 <span class="watched-none">No folders watched — click Choose to add one</span>
15867 {% endif %}
15868 </div>
15869 </div>
15870 <div class="watched-bar-right">
15871 <button type="button" class="btn" id="add-watched-btn">
15872 <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>
15873 Choose
15874 </button>
15875 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
15876 <input type="hidden" name="redirect_to" value="/view-reports">
15877 <button type="submit" class="btn">↻ Refresh</button>
15878 </form>
15879 </div>
15880 </div>
15881 {% if total_scans > 0 %}
15882 <div class="summary-strip">
15883 <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>
15884 <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>
15885 <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>
15886 <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>
15887 </div>
15888 {% endif %}
15889
15890 <section class="panel">
15891 <div class="panel-header">
15892 <div>
15893 <h1>View Reports</h1>
15894 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
15895 </div>
15896 <div class="flex-row">
15897 <button type="button" class="export-btn" id="export-csv-btn">
15898 <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>
15899 Export CSV
15900 </button>
15901 <button type="button" class="export-btn" id="export-xls-btn">
15902 <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>
15903 Export Excel
15904 </button>
15905 </div>
15906 </div>
15907
15908 {% if entries.is_empty() %}
15909 <div class="empty-state">
15910 <strong>No reports with viewable HTML yet</strong>
15911 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.
15912 </div>
15913 {% else %}
15914 <div class="filter-row">
15915 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
15916 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
15917 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
15918 </div>
15919 <div class="table-wrap">
15920 <table id="history-table">
15921 <colgroup>
15922 <col><col><col><col><col><col><col><col><col><col>
15923 </colgroup>
15924 <thead>
15925 <tr id="history-thead">
15926 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15927 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15928 <th>Run ID<div class="col-resize-handle"></div></th>
15929 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15930 <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>
15931 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15932 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15933 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15934 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
15935 <th>Report<div class="col-resize-handle"></div></th>
15936 </tr>
15937 </thead>
15938 <tbody id="history-tbody">
15939 {% for entry in entries %}
15940 <tr class="history-row" data-run="{{ entry.run_id }}"
15941 data-timestamp="{{ entry.timestamp }}"
15942 data-project="{{ entry.project_label }}"
15943 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
15944 data-skipped="{{ entry.files_skipped }}"
15945 data-comments="{{ entry.comment_lines }}"
15946 data-blank="{{ entry.blank_lines }}"
15947 data-branch="{{ entry.git_branch }}"
15948 data-commit="{{ entry.git_commit }}"
15949 data-html-url="/runs/html/{{ entry.run_id }}">
15950 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
15951 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
15952 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
15953 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
15954 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
15955 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
15956 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
15957 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
15958 <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>
15959 <td class="report-cell">
15960 <div class="actions-cell">
15961 {% 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 %}
15962 {% 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 %}
15963 </div>
15964 {% if !entry.submodule_links.is_empty() %}
15965 <details class="submod-details">
15966 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
15967 <div class="submod-link-list">
15968 {% for sub in entry.submodule_links %}
15969 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
15970 {% endfor %}
15971 </div>
15972 </details>
15973 {% endif %}
15974 </td>
15975 </tr>
15976 {% endfor %}
15977 </tbody>
15978 </table>
15979 </div>
15980 <div class="pagination">
15981 <span class="pagination-info" id="pagination-info"></span>
15982 <div class="pagination-btns" id="pagination-btns"></div>
15983 <div class="flex-row">
15984 <span class="per-page-label">Show</span>
15985 <select class="per-page" id="per-page-sel">
15986 <option value="10">10 per page</option>
15987 <option value="25" selected>25 per page</option>
15988 <option value="50">50 per page</option>
15989 <option value="100">100 per page</option>
15990 </select>
15991 <span class="per-page-label" id="page-range-label"></span>
15992 </div>
15993 </div>
15994 {% endif %}
15995 </section>
15996 </div>
15997
15998 <footer class="site-footer">
15999 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
16000 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16001 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16002 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16003 · <a href="/api-docs" rel="noopener">REST API</a>
16004 </footer>
16005
16006 <script nonce="{{ csp_nonce }}">
16007 (function () {
16008 // ── Theme ──────────────────────────────────────────────────────────────
16009 var storageKey = 'oxide-sloc-theme';
16010 var body = document.body;
16011 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16012 var toggle = document.getElementById('theme-toggle');
16013 if (toggle) toggle.addEventListener('click', function () {
16014 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16015 body.classList.toggle('dark-theme', next === 'dark');
16016 try { localStorage.setItem(storageKey, next); } catch(e) {}
16017 });
16018
16019 // ── State ─────────────────────────────────────────────────────────────
16020 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
16021 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
16022 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16023
16024 // Aggregate stats from first (most recent) row
16025 if (allRows.length) {
16026 var first = allRows[0];
16027 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();}
16028 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>':'');}
16029 setChipVal('agg-code', first.dataset.code);
16030 setChipVal('agg-files', first.dataset.files);
16031 setChipVal('agg-skipped', first.dataset.skipped);
16032 }
16033
16034 // ── Branch filter population ──────────────────────────────────────────
16035 (function() {
16036 var branches = {};
16037 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16038 var sel = document.getElementById('branch-filter');
16039 if (sel) Object.keys(branches).sort().forEach(function(b) {
16040 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16041 });
16042 })();
16043
16044 // ── Filter ────────────────────────────────────────────────────────────
16045 function getFilteredRows() {
16046 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16047 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16048 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16049 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16050 if (branch && (r.dataset.branch || '') !== branch) return false;
16051 return true;
16052 });
16053 }
16054
16055 // ── Pagination ────────────────────────────────────────────────────────
16056 function renderPage() {
16057 var filtered = getFilteredRows();
16058 var total = filtered.length;
16059 var totalPages = Math.max(1, Math.ceil(total / perPage));
16060 currentPage = Math.min(currentPage, totalPages);
16061 var start = (currentPage - 1) * perPage;
16062 var end = Math.min(start + perPage, total);
16063 var shown = {};
16064 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16065 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16066 r.style.display = shown[r.dataset.run] ? '' : 'none';
16067 });
16068 var rl = document.getElementById('page-range-label');
16069 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16070 var info = document.getElementById('pagination-info');
16071 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16072 var btns = document.getElementById('pagination-btns');
16073 if (!btns) return;
16074 btns.innerHTML = '';
16075 function makeBtn(lbl, pg, active, disabled) {
16076 var b = document.createElement('button');
16077 b.className = 'pg-btn' + (active ? ' active' : '');
16078 b.textContent = lbl; b.disabled = disabled;
16079 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16080 return b;
16081 }
16082 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16083 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16084 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16085 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16086 }
16087
16088 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16089 window.applyFilters = function() { currentPage = 1; renderPage(); };
16090
16091 // ── Sorting ───────────────────────────────────────────────────────────
16092 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16093 function doSort(col, type, order) {
16094 var tbody = document.getElementById('history-tbody');
16095 if (!tbody) return;
16096 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16097 rows.sort(function(a, b) {
16098 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16099 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16100 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16101 return va < vb ? 1 : va > vb ? -1 : 0;
16102 });
16103 rows.forEach(function(r) { tbody.appendChild(r); });
16104 currentPage = 1; renderPage();
16105 }
16106 sortHeaders.forEach(function(th) {
16107 th.addEventListener('click', function(e) {
16108 if (e.target.classList.contains('col-resize-handle')) return;
16109 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16110 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16111 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16112 th.classList.add('sort-' + sortOrder);
16113 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16114 doSort(col, type, sortOrder);
16115 });
16116 });
16117
16118 // ── Column resize ─────────────────────────────────────────────────────
16119 (function() {
16120 var table = document.getElementById('history-table');
16121 if (!table) return;
16122 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16123 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16124 ths.forEach(function(th, i) {
16125 var handle = th.querySelector('.col-resize-handle');
16126 if (!handle || !cols[i]) return;
16127 var startX, startW;
16128 handle.addEventListener('mousedown', function(e) {
16129 e.stopPropagation(); e.preventDefault();
16130 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16131 handle.classList.add('dragging');
16132 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16133 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16134 document.addEventListener('mousemove', onMove);
16135 document.addEventListener('mouseup', onUp);
16136 });
16137 });
16138 })();
16139
16140 // ── Reset view ────────────────────────────────────────────────────────
16141 window.resetView = function() {
16142 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16143 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16144 sortCol = null; sortOrder = 'asc';
16145 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16146 var tbody = document.getElementById('history-tbody');
16147 if (tbody) {
16148 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16149 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16150 rows.forEach(function(r) { tbody.appendChild(r); });
16151 }
16152 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16153 var table = document.getElementById('history-table');
16154 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16155 currentPage = 1; renderPage();
16156 };
16157
16158 renderPage();
16159
16160 // ── Export helpers ────────────────────────────────────────────────────
16161 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
16162 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16163 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);}
16164 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;');}
16165 function slocXlsx(fname,sheet,hdrs,rows){
16166 var enc=new TextEncoder();
16167 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;}
16168 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;}
16169 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16170 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16171 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16172 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;}
16173 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];}
16174 var rx='<row r="1">';
16175 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16176 rx+='</row>';
16177 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>';});
16178 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16179 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>';
16180 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>';
16181 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>';
16182 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>',
16183 '_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>',
16184 '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>',
16185 '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>',
16186 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16187 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'];
16188 var zparts=[],zcds=[],zoff=0,znf=0;
16189 order.forEach(function(name){
16190 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16191 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]);
16192 var entry=new Uint8Array(lha.length+nb.length+sz);
16193 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16194 zparts.push(entry);
16195 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));
16196 var cde=new Uint8Array(cda.length+nb.length);
16197 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16198 zcds.push(cde);zoff+=entry.length;znf++;
16199 });
16200 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16201 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]);
16202 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16203 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16204 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16205 zout.set(new Uint8Array(ea),zpos);
16206 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16207 }
16208
16209 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16210 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;}
16211 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16212 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16213
16214 var csvBtn = document.getElementById('export-csv-btn');
16215 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16216 var xlsBtn = document.getElementById('export-xls-btn');
16217 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16218
16219 // ── Remaining CSP-safe event bindings ────────────────────────────────
16220 (function wireEvents() {
16221 var el;
16222 el = document.getElementById('reset-view-btn');
16223 if (el) el.addEventListener('click', window.resetView);
16224 el = document.getElementById('project-filter');
16225 if (el) el.addEventListener('input', window.applyFilters);
16226 el = document.getElementById('branch-filter');
16227 if (el) el.addEventListener('change', window.applyFilters);
16228 el = document.getElementById('per-page-sel');
16229 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16230 el = document.getElementById('add-watched-btn');
16231 if (el) el.addEventListener('click', function() {
16232 fetch('/pick-directory?kind=reports')
16233 .then(function(r) { return r.json(); })
16234 .then(function(data) {
16235 if (!data.cancelled && data.selected_path) {
16236 var form = document.createElement('form');
16237 form.method = 'POST';
16238 form.action = '/watched-dirs/add';
16239 var ri = document.createElement('input');
16240 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16241 var fi = document.createElement('input');
16242 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16243 form.appendChild(ri); form.appendChild(fi);
16244 document.body.appendChild(form);
16245 form.submit();
16246 }
16247 })
16248 .catch(function(e) { alert('Could not open folder picker: ' + e); });
16249 });
16250 })();
16251
16252 (function randomizeWatermarks() {
16253 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16254 if (!wms.length) return;
16255 var placed = [];
16256 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;}
16257 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];}
16258 var half=Math.floor(wms.length/2);
16259 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;});
16260 })();
16261
16262 (function spawnCodeParticles() {
16263 var container = document.getElementById('code-particles');
16264 if (!container) return;
16265 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'];
16266 for (var i = 0; i < 38; i++) {
16267 (function(idx) {
16268 var el = document.createElement('span');
16269 el.className = 'code-particle';
16270 el.textContent = snippets[idx % snippets.length];
16271 var left = Math.random() * 94 + 2;
16272 var top = Math.random() * 88 + 6;
16273 var dur = (Math.random() * 10 + 9).toFixed(1);
16274 var delay = (Math.random() * 18).toFixed(1);
16275 var rot = (Math.random() * 26 - 13).toFixed(1);
16276 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16277 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';
16278 container.appendChild(el);
16279 })(i);
16280 }
16281 })();
16282 })();
16283 </script>
16284 <script nonce="{{ csp_nonce }}">
16285 (function(){
16286 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'}];
16287 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);});}
16288 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16289 function init(){
16290 var btn=document.getElementById('settings-btn');if(!btn)return;
16291 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16292 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>';
16293 document.body.appendChild(m);
16294 var g=document.getElementById('scheme-grid');
16295 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);});
16296 var cl=document.getElementById('settings-close');
16297 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);
16298 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');});
16299 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16300 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16301 }
16302 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16303 }());
16304 </script>
16305</body>
16306</html>
16307"##,
16308 ext = "html"
16309)]
16310struct HistoryTemplate {
16311 version: &'static str,
16312 entries: Vec<HistoryEntryRow>,
16313 total_scans: usize,
16314 linked_count: usize,
16315 browse_error: Option<String>,
16316 watched_dirs: Vec<String>,
16317 csp_nonce: String,
16318}
16319
16320#[derive(Template)]
16323#[template(
16324 source = r##"
16325<!doctype html>
16326<html lang="en">
16327<head>
16328 <meta charset="utf-8">
16329 <meta name="viewport" content="width=device-width, initial-scale=1">
16330 <title>OxideSLOC | Compare Scans</title>
16331 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16332 <style nonce="{{ csp_nonce }}">
16333 :root {
16334 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16335 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16336 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16337 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16338 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16339 }
16340 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16341 *{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);}
16342 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16343 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16344 .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);}
16345 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16346 .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));}
16347 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16348 .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;}
16349 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16350 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16351 @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; } }
16352 .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;}
16353 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16354 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16355 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16356 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16357 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16358 .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;}
16359 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16360 .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);}
16361 .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;}
16362 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16363 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16364 .settings-modal-body{padding:14px 16px 16px;}
16365 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16366 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16367 .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;}
16368 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16369 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16370 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16371 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16372 .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;}
16373 .tz-select:focus{border-color:var(--oxide);}
16374 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16375 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16376 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16377 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16378 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16379 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16380 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16381 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16382 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16383 .per-page-label{font-size:13px;color:var(--muted);}
16384 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;}
16385 .filter-input{min-width:180px;cursor:text;}
16386 .table-wrap{width:100%;overflow-x:auto;}
16387 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16388 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;}
16389 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16390 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16391 #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;}
16392 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16393 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16394 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16395 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16396 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16397 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16398 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16399 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16400 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16401 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16402 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16403 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16404 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16405 tr:last-child td{border-bottom:none;}
16406 tr.selected td{background:var(--sel-bg);}
16407 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16408 tr:hover:not(.selected) td{background:var(--surface-2);}
16409 tr{cursor:pointer;}
16410 .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);}
16411 .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);}
16412 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16413 .metric-num{font-weight:700;color:var(--text);}
16414 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16415 .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;}
16416 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16417 .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;}
16418 .btn:hover{background:var(--line);}
16419 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16420 .btn.primary:hover{opacity:.9;}
16421 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16422 .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;}
16423 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16424 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16425 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16426 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16427 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16428 .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;}
16429 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16430 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16431 .watched-chip-rm:hover{color:var(--oxide);}
16432 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16433 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16434 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16435 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16436 .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;}
16437 .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;}
16438 .btn-back:hover{background:var(--line);}
16439 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16440 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16441 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16442 .pagination-info{font-size:13px;color:var(--muted);}
16443 .pagination-btns{display:flex;gap:6px;}
16444 .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;}
16445 .pg-btn:hover:not(:disabled){background:var(--line);}
16446 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16447 .pg-btn:disabled{opacity:.35;cursor:default;}
16448 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16449 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16450 .site-footer a{color:var(--muted);}
16451 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16452 .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;}
16453 .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;}
16454 .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;}
16455 @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));}}
16456 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16457 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16458 .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;}
16459 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16460 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16461 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16462 .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);}
16463 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16464 .stat-chip:hover .stat-chip-tip{opacity:1;}
16465 .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;}
16466 .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;}
16467 .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%;}
16468 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16469 .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;}
16470 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16471 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16472 .hidden{display:none!important;}
16473 .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%;}
16474 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16475 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16476 .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;}
16477 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16478 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16479 .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;}
16480 .scope-option:hover{background:var(--line);}
16481 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16482 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16483 .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;}
16484 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16485 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16486 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16487 .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;}
16488 </style>
16489</head>
16490<body>
16491 <div class="background-watermarks" aria-hidden="true">
16492 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16493 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16494 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16495 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16496 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16497 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16498 </div>
16499 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16500 <div class="top-nav">
16501 <div class="top-nav-inner">
16502 <a class="brand" href="/">
16503 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16504 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16505 </a>
16506 <div class="nav-right">
16507 <a class="nav-pill" href="/">Home</a>
16508 <div class="nav-dropdown">
16509 <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>
16510 <div class="nav-dropdown-menu">
16511 <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>
16512 </div>
16513 </div>
16514 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16515 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16516 <div class="nav-dropdown">
16517 <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>
16518 <div class="nav-dropdown-menu">
16519 <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>
16520 </div>
16521 </div>
16522 <div class="server-status-wrap">
16523 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16524 <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>
16525 </div>
16526 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16527 <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>
16528 </button>
16529 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16530 <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>
16531 <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>
16532 </button>
16533 </div>
16534 </div>
16535 </div>
16536
16537 <div class="page">
16538 <div class="watched-bar">
16539 <div class="watched-bar-left">
16540 <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>
16541 <span class="watched-label">Watched Folders</span>
16542 <div class="watched-chips">
16543 {% for dir in watched_dirs %}
16544 <span class="watched-chip">
16545 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16546 <form method="POST" action="/watched-dirs/remove" style="display:contents">
16547 <input type="hidden" name="folder_path" value="{{ dir }}">
16548 <input type="hidden" name="redirect_to" value="/compare-scans">
16549 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
16550 </form>
16551 </span>
16552 {% endfor %}
16553 {% if watched_dirs.is_empty() %}
16554 <span class="watched-none">No folders watched — click Choose to add one</span>
16555 {% endif %}
16556 </div>
16557 </div>
16558 <div class="watched-bar-right">
16559 <button type="button" class="btn" id="add-watched-btn">
16560 <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>
16561 Choose
16562 </button>
16563 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16564 <input type="hidden" name="redirect_to" value="/compare-scans">
16565 <button type="submit" class="btn">↻ Refresh</button>
16566 </form>
16567 </div>
16568 </div>
16569 {% if total_scans > 0 %}
16570 <div class="summary-strip">
16571 <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>
16572 <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>
16573 <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>
16574 <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>
16575 </div>
16576 {% endif %}
16577 <section class="panel">
16578 <div class="panel-header">
16579 <div>
16580 <h1>Compare Scans</h1>
16581 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16582 </div>
16583 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16584 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16585 <button class="btn primary" id="compare-btn" disabled>
16586 <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>
16587 Compare <span class="sel-count" id="sel-count">0/2</span>
16588 </button>
16589 </div>
16590 </div>
16591 </div>
16592
16593 {% if entries.is_empty() %}
16594 <div class="empty-state">
16595 <strong>No scans yet</strong>
16596 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.
16597 </div>
16598 {% else %}
16599 <div class="filter-row">
16600 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16601 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16602 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
16603 </div>
16604 <div class="scope-panel hidden" id="scope-panel">
16605 <div class="scope-panel-label">
16606 <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>
16607 Compare scope — choose what to include
16608 </div>
16609 <div class="scope-options" id="scope-options"></div>
16610 </div>
16611 {% if total_scans > 0 %}
16612 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16613 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16614 <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>
16615 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16616 </div>
16617 </div>
16618 {% endif %}
16619 <div class="table-wrap">
16620 <table id="compare-table">
16621 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16622 <thead>
16623 <tr id="compare-thead">
16624 <th><div class="col-resize-handle"></div></th>
16625 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16626 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16627 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16628 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16629 <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>
16630 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16631 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16632 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16633 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16634 <th>Submodules<div class="col-resize-handle"></div></th>
16635 </tr>
16636 </thead>
16637 <tbody id="compare-tbody">
16638 {% for entry in entries %}
16639 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16640 data-timestamp="{{ entry.timestamp }}"
16641 data-project="{{ entry.project_label }}"
16642 data-files="{{ entry.files_analyzed }}"
16643 data-code="{{ entry.code_lines }}"
16644 data-comments="{{ entry.comment_lines }}"
16645 data-blank="{{ entry.blank_lines }}"
16646 data-branch="{{ entry.git_branch }}"
16647 data-commit="{{ entry.git_commit }}"
16648 data-submodules="{{ entry.submodule_names_csv }}">
16649 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16650 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16651 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16652 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16653 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16654 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16655 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16656 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16657 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16658 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16659 <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>
16660 </tr>
16661 {% endfor %}
16662 </tbody>
16663 </table>
16664 </div>
16665 <div class="pagination">
16666 <span class="pagination-info" id="pagination-info"></span>
16667 <div class="pagination-btns" id="pagination-btns"></div>
16668 <div class="flex-row">
16669 <span class="per-page-label">Show</span>
16670 <select class="per-page" id="per-page-sel">
16671 <option value="10">10 per page</option>
16672 <option value="25" selected>25 per page</option>
16673 <option value="50">50 per page</option>
16674 <option value="100">100 per page</option>
16675 </select>
16676 <span class="per-page-label" id="page-range-label"></span>
16677 </div>
16678 </div>
16679 {% endif %}
16680 </section>
16681 </div>
16682
16683 <footer class="site-footer">
16684 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
16685 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16686 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16687 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16688 · <a href="/api-docs" rel="noopener">REST API</a>
16689 </footer>
16690
16691 <script nonce="{{ csp_nonce }}">
16692 (function () {
16693 // ── Theme ──────────────────────────────────────────────────────────────
16694 var storageKey = 'oxide-sloc-theme';
16695 var body = document.body;
16696 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16697 var toggle = document.getElementById('theme-toggle');
16698 if (toggle) toggle.addEventListener('click', function () {
16699 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16700 body.classList.toggle('dark-theme', next === 'dark');
16701 try { localStorage.setItem(storageKey, next); } catch(e) {}
16702 });
16703
16704 // ── State ─────────────────────────────────────────────────────────────
16705 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16706 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16707 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16708
16709 // ── Stat chips ────────────────────────────────────────────────────────
16710 (function() {
16711 var projects = {}, latestTs = '', latestRow = null;
16712 allRows.forEach(function(r) {
16713 var p = r.dataset.project || ''; if (p) projects[p] = true;
16714 var ts = r.dataset.timestamp || '';
16715 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16716 });
16717 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();}
16718 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>':'');}
16719 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16720 if (latestRow) {
16721 setChipVal('agg-code', latestRow.dataset.code);
16722 setChipVal('agg-files', latestRow.dataset.files);
16723 }
16724 })();
16725
16726 // ── Branch filter population ──────────────────────────────────────────
16727 (function() {
16728 var branches = {};
16729 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16730 var sel = document.getElementById('branch-filter');
16731 if (sel) Object.keys(branches).sort().forEach(function(b) {
16732 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16733 });
16734 })();
16735
16736 // ── Filter ────────────────────────────────────────────────────────────
16737 function getFilteredRows() {
16738 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16739 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16740 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16741 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16742 if (branch && (r.dataset.branch || '') !== branch) return false;
16743 return true;
16744 });
16745 }
16746
16747 // ── Pagination ────────────────────────────────────────────────────────
16748 function renderPage() {
16749 var filtered = getFilteredRows();
16750 var total = filtered.length;
16751 var totalPages = Math.max(1, Math.ceil(total / perPage));
16752 currentPage = Math.min(currentPage, totalPages);
16753 var start = (currentPage - 1) * perPage;
16754 var end = Math.min(start + perPage, total);
16755 var shown = {};
16756 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16757 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16758 r.style.display = shown[r.dataset.run] ? '' : 'none';
16759 });
16760 var rl = document.getElementById('page-range-label');
16761 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16762 var info = document.getElementById('pagination-info');
16763 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16764 var btns = document.getElementById('pagination-btns');
16765 if (!btns) return;
16766 btns.innerHTML = '';
16767 function makeBtn(lbl, pg, active, disabled) {
16768 var b = document.createElement('button');
16769 b.className = 'pg-btn' + (active ? ' active' : '');
16770 b.textContent = lbl; b.disabled = disabled;
16771 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16772 return b;
16773 }
16774 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16775 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16776 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16777 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16778 }
16779
16780 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16781 window.applyFilters = function() { currentPage = 1; renderPage(); };
16782
16783 // ── Sorting ───────────────────────────────────────────────────────────
16784 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16785 function doSort(col, type, order) {
16786 var tbody = document.getElementById('compare-tbody');
16787 if (!tbody) return;
16788 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16789 rows.sort(function(a, b) {
16790 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16791 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16792 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16793 return va < vb ? 1 : va > vb ? -1 : 0;
16794 });
16795 rows.forEach(function(r) { tbody.appendChild(r); });
16796 currentPage = 1; renderPage();
16797 }
16798 sortHeaders.forEach(function(th) {
16799 th.addEventListener('click', function(e) {
16800 if (e.target.classList.contains('col-resize-handle')) return;
16801 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16802 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16803 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16804 th.classList.add('sort-' + sortOrder);
16805 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16806 doSort(col, type, sortOrder);
16807 });
16808 });
16809
16810 // Apply default sort (timestamp desc) on initial load
16811 (function() {
16812 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
16813 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
16814 })();
16815
16816 // ── Column resize ─────────────────────────────────────────────────────
16817 (function() {
16818 var table = document.getElementById('compare-table');
16819 if (!table) return;
16820 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16821 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
16822 ths.forEach(function(th, i) {
16823 var handle = th.querySelector('.col-resize-handle');
16824 if (!handle || !cols[i]) return;
16825 var startX, startW;
16826 handle.addEventListener('mousedown', function(e) {
16827 e.stopPropagation(); e.preventDefault();
16828 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16829 handle.classList.add('dragging');
16830 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16831 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16832 document.addEventListener('mousemove', onMove);
16833 document.addEventListener('mouseup', onUp);
16834 });
16835 });
16836 })();
16837
16838 // ── Reset view ────────────────────────────────────────────────────────
16839 window.resetView = function() {
16840 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16841 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16842 sortCol = null; sortOrder = 'asc';
16843 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16844 var tbody = document.getElementById('compare-tbody');
16845 if (tbody) {
16846 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16847 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16848 rows.forEach(function(r) { tbody.appendChild(r); });
16849 }
16850 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16851 var table = document.getElementById('compare-table');
16852 currentPage = 1; renderPage();
16853 currentPage = 1; renderPage();
16854 };
16855
16856 renderPage();
16857
16858 // ── Row selection state ───────────────────────────────────────────────
16859 var selected = [];
16860 function updateCompareBtn() {
16861 var btn = document.getElementById('compare-btn');
16862 var cnt = document.getElementById('sel-count');
16863 if (!btn) return;
16864 btn.disabled = selected.length !== 2;
16865 if (cnt) cnt.textContent = selected.length + '/2';
16866 }
16867
16868 function toggleRow(row) {
16869 var vid = row.dataset.vid || row.dataset.run;
16870 var idx = selected.indexOf(vid);
16871 if (idx >= 0) {
16872 selected.splice(idx, 1);
16873 row.classList.remove('selected');
16874 var b = document.getElementById('badge-' + vid);
16875 if (b) b.textContent = '';
16876 } else {
16877 if (selected.length >= 2) return;
16878 selected.push(vid);
16879 row.classList.add('selected');
16880 }
16881 selected.forEach(function(v, i) {
16882 var b = document.getElementById('badge-' + v);
16883 if (b) b.textContent = i + 1;
16884 });
16885 updateCompareBtn();
16886 buildScopePanel();
16887 }
16888
16889 // ── Scope panel ───────────────────────────────────────────────────────
16890 var selectedScope = 'all';
16891
16892 function buildScopePanel() {
16893 var panel = document.getElementById('scope-panel');
16894 var opts = document.getElementById('scope-options');
16895 if (!panel || !opts) return;
16896 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16897
16898 // Collect union of submodules from both selected rows.
16899 var allSubs = {};
16900 selected.forEach(function(vid) {
16901 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
16902 if (!row) return;
16903 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
16904 });
16905 var subList = Object.keys(allSubs).sort();
16906 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16907
16908 panel.classList.remove('hidden');
16909 opts.innerHTML = '';
16910
16911 function makeOption(value, label, title) {
16912 var div = document.createElement('div');
16913 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
16914 div.dataset.scopeValue = value;
16915 if (title) div.title = title;
16916 var radio = document.createElement('span');
16917 radio.className = 'scope-option-radio';
16918 var lbl = document.createElement('span');
16919 lbl.textContent = label;
16920 div.appendChild(radio);
16921 div.appendChild(lbl);
16922 div.addEventListener('click', function() {
16923 selectedScope = value;
16924 opts.querySelectorAll('.scope-option').forEach(function(o) {
16925 o.classList.toggle('selected', o.dataset.scopeValue === value);
16926 });
16927 });
16928 return div;
16929 }
16930
16931 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
16932 var sep = document.createElement('span');
16933 sep.className = 'scope-option-sep';
16934 opts.appendChild(sep);
16935 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
16936 subList.forEach(function(s) {
16937 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
16938 });
16939 }
16940
16941 function doCompare() {
16942 if (selected.length !== 2) return;
16943 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
16944 if (selectedScope === 'super') url += '&scope=super';
16945 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
16946 window.location.href = url;
16947 }
16948
16949 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
16950 var cbtn = document.getElementById('compare-btn');
16951 if (cbtn) cbtn.addEventListener('click', doCompare);
16952 var pfEl = document.getElementById('project-filter');
16953 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
16954 var bfEl = document.getElementById('branch-filter');
16955 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
16956 var rvBtn = document.getElementById('reset-view-btn');
16957 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
16958 var ppSel = document.getElementById('per-page-sel');
16959 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
16960
16961 var cmpTbody = document.getElementById('compare-tbody');
16962 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
16963 var row = e.target.closest('.compare-row');
16964 if (row) toggleRow(row);
16965 });
16966
16967 (function randomizeWatermarks() {
16968 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16969 if (!wms.length) return;
16970 var placed = [];
16971 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;}
16972 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];}
16973 var half=Math.floor(wms.length/2);
16974 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;});
16975 })();
16976
16977 (function spawnCodeParticles() {
16978 var container = document.getElementById('code-particles');
16979 if (!container) return;
16980 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'];
16981 for (var i = 0; i < 38; i++) {
16982 (function(idx) {
16983 var el = document.createElement('span');
16984 el.className = 'code-particle';
16985 el.textContent = snippets[idx % snippets.length];
16986 var left = Math.random() * 94 + 2;
16987 var top = Math.random() * 88 + 6;
16988 var dur = (Math.random() * 10 + 9).toFixed(1);
16989 var delay = (Math.random() * 18).toFixed(1);
16990 var rot = (Math.random() * 26 - 13).toFixed(1);
16991 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16992 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';
16993 container.appendChild(el);
16994 })(i);
16995 }
16996 })();
16997
16998 // ── Watched folder picker ─────────────────────────────────────────────
16999 (function() {
17000 var btn = document.getElementById('add-watched-btn');
17001 if (!btn) return;
17002 btn.addEventListener('click', function() {
17003 fetch('/pick-directory?kind=reports')
17004 .then(function(r) { return r.json(); })
17005 .then(function(data) {
17006 if (!data.cancelled && data.selected_path) {
17007 var form = document.createElement('form');
17008 form.method = 'POST';
17009 form.action = '/watched-dirs/add';
17010 var ri = document.createElement('input');
17011 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17012 var fi = document.createElement('input');
17013 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17014 form.appendChild(ri); form.appendChild(fi);
17015 document.body.appendChild(form);
17016 form.submit();
17017 }
17018 })
17019 .catch(function(e) { alert('Could not open folder picker: ' + e); });
17020 });
17021 })();
17022
17023 // ── Submodule chip truncation ─────────────────────────────────────────
17024 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17025 var chips = cell.querySelectorAll('.submod-chip');
17026 var MAX = 4;
17027 if (chips.length <= MAX) return;
17028 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17029 var badge = document.createElement('span');
17030 badge.className = 'submod-overflow-badge';
17031 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17032 badge.textContent = '+' + (chips.length - MAX) + ' more';
17033 cell.appendChild(badge);
17034 cell.style.maxHeight = 'none';
17035 });
17036 })();
17037 </script>
17038 <script nonce="{{ csp_nonce }}">
17039 (function(){
17040 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'}];
17041 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);});}
17042 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17043 function init(){
17044 var btn=document.getElementById('settings-btn');if(!btn)return;
17045 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17046 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>';
17047 document.body.appendChild(m);
17048 var g=document.getElementById('scheme-grid');
17049 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);});
17050 var cl=document.getElementById('settings-close');
17051 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);
17052 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');});
17053 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17054 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17055 }
17056 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17057 }());
17058 </script>
17059</body>
17060</html>
17061"##,
17062 ext = "html"
17063)]
17064struct CompareSelectTemplate {
17065 version: &'static str,
17066 entries: Vec<HistoryEntryRow>,
17067 total_scans: usize,
17068 watched_dirs: Vec<String>,
17069 csp_nonce: String,
17070}
17071
17072#[derive(Template)]
17075#[template(
17076 source = r##"
17077<!doctype html>
17078<html lang="en">
17079<head>
17080 <meta charset="utf-8">
17081 <meta name="viewport" content="width=device-width, initial-scale=1">
17082 <title>OxideSLOC | Scan Delta</title>
17083 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17084 <style nonce="{{ csp_nonce }}">
17085 :root {
17086 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17087 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17088 --nav:#283790; --nav-2:#013e6b;
17089 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17090 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17091 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17092 }
17093 body.dark-theme {
17094 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17095 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17096 }
17097 *{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);}
17098 .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);}
17099 .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;}
17100 .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));}
17101 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17102 .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;}
17103 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17104 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17105 @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; } }
17106 .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;}
17107 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17108 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17109 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17110 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17111 .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;}
17112 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17113 .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);}
17114 .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;}
17115 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17116 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17117 .settings-modal-body{padding:14px 16px 16px;}
17118 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17119 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17120 .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;}
17121 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17122 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17123 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17124 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17125 .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;}
17126 .tz-select:focus{border-color:var(--oxide);}
17127 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17128 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17129 .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;}
17130 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17131 .hero-body{display:block;}
17132 .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;}
17133 .btn-back:hover{background:var(--line);}
17134 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17135 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17136 .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;}
17137 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17138 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;}
17139 .muted{color:var(--muted);font-size:14px;}
17140 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17141 .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;}
17142 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17143 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17144 .vpill-arrow{font-size:20px;color:var(--muted);}
17145 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17146 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17147 .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;}
17148 .delta-card.delta-card-wide{padding:22px 24px;}
17149 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17150 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17151 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17152 .delta-card-from{font-size:15px;color:var(--muted);}
17153 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17154 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17155 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17156 .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%;}
17157 .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;}
17158 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17159 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17160 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17161 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17162 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17163 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17164 .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;}
17165 .meta-card-commit:hover{color:var(--oxide);}
17166 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17167 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17168 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17169 .meta-value{color:var(--text);font-size:13px;}
17170 .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;}
17171 .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);}
17172 .delta-card:hover .dc-tip{display:block;}
17173 .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;}
17174 .export-btn:hover{background:var(--line);}
17175 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17176 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17177 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17178 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17179 .delta-card-change.zero{color:var(--muted);background:transparent;}
17180 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17181 .delta-card-pct.pos{color:var(--pos);}
17182 .delta-card-pct.neg{color:var(--neg);}
17183 .delta-card-pct.zero{color:var(--muted);}
17184 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17185 .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;}
17186 .insight-card.insight-flag{border-color:var(--oxide);}
17187 .insight-card:hover .dc-tip{display:block;}
17188 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17189 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17190 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17191 .insight-label.flag{color:var(--oxide);}
17192 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17193 .insight-val.pos{color:var(--pos);}
17194 .insight-val.neg{color:var(--neg);}
17195 .insight-val.high{color:#c0392a;}
17196 .insight-val.med{color:#926000;}
17197 .insight-val.low{color:var(--pos);}
17198 body.dark-theme .insight-val.high{color:#ff6b6b;}
17199 body.dark-theme .insight-val.med{color:#f0c060;}
17200 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17201 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17202 .fc-row{display:flex;align-items:center;gap:8px;}
17203 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17204 .fc-label{color:var(--muted);}
17205 .fc-modified .fc-count{color:#926000;}
17206 .fc-added .fc-count{color:var(--pos);}
17207 .fc-removed .fc-count{color:var(--neg);}
17208 .fc-unchanged .fc-count{color:var(--muted);}
17209 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17210 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17211 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17212 .chip.modified{background:#fff2d8;color:#926000;}
17213 .chip.added{background:#e8f5ed;color:#1a8f47;}
17214 .chip.removed{background:#fdeaea;color:#b33b3b;}
17215 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17216 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17217 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17218 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17219 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17220 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17221 .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;}
17222 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17223 .tab-btn:hover:not(.active){background:var(--line);}
17224 .btn-reset{padding:6px 14px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
17225 .btn-reset:hover{background:var(--line);}
17226 .table-wrap{width:100%;overflow-x:auto;}
17227 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17228 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;}
17229 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17230 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17231 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17232 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17233 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17234 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17235 tr:last-child td{border-bottom:none;}
17236 tr.row-added td{background:rgba(26,143,71,0.06);}
17237 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17238 tr.row-modified td{background:rgba(146,96,0,0.05);}
17239 tr.row-unchanged td{opacity:.6;}
17240 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17241 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17242 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17243 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17244 .status-badge.modified{background:#fff2d8;color:#926000;}
17245 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17246 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17247 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17248 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17249 .delta-val{font-weight:700;}
17250 .delta-val.pos{color:var(--pos);}
17251 .delta-val.neg{color:var(--neg);}
17252 .delta-val.zero{color:var(--muted);}
17253 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17254 .from-to strong{color:var(--text);}
17255 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17256 .site-footer a{color:var(--muted);}
17257 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17258 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17259 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17260 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17261 .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;}
17262 .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;}
17263 .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;}
17264 @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));}}
17265 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17266 .path-link:hover{color:var(--oxide-2);}
17267 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17268 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17269 a.vpill-id:hover{color:var(--oxide);}
17270 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17271 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17272 .pagination-info{font-size:13px;color:var(--muted);}
17273 .pagination-btns{display:flex;gap:6px;}
17274 .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;}
17275 .pg-btn:hover:not(:disabled){background:var(--line);}
17276 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17277 .pg-btn:disabled{opacity:.35;cursor:default;}
17278 .per-page-label{font-size:13px;color:var(--muted);}
17279 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;}
17280 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17281 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17282 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17283 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17284 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17285 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17286 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17287 .tab-btn.tab-unchanged{color:var(--muted);}
17288 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17289 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17290 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17291 .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;}
17292 .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;}
17293 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17294 .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;}
17295 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17296 .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;}
17297 .submod-scope-btn:hover{background:var(--line);}
17298 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17299 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17300 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17301 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17302 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17303 body.dark-theme .ic-card{background:var(--surface-2);}
17304 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17305 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17306 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17307 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17308 #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;}
17309 </style>
17310</head>
17311<body>
17312 <div class="background-watermarks" aria-hidden="true">
17313 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17314 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17315 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17316 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17317 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17318 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17319 </div>
17320 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17321 <div class="top-nav">
17322 <div class="top-nav-inner">
17323 <a class="brand" href="/">
17324 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17325 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17326 </a>
17327 <div class="nav-right">
17328 <a class="nav-pill" href="/">Home</a>
17329 <div class="nav-dropdown">
17330 <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>
17331 <div class="nav-dropdown-menu">
17332 <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>
17333 </div>
17334 </div>
17335 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17336 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17337 <div class="nav-dropdown">
17338 <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>
17339 <div class="nav-dropdown-menu">
17340 <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>
17341 </div>
17342 </div>
17343 <div class="server-status-wrap">
17344 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17345 <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>
17346 </div>
17347 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17348 <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>
17349 </button>
17350 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17351 <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>
17352 <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>
17353 </button>
17354 </div>
17355 </div>
17356 </div>
17357
17358 <div class="page">
17359 <section class="hero">
17360 <div class="hero-header">
17361 <div>
17362 <h1 class="delta-title">Scan Delta</h1>
17363 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17364 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17365 {% if let Some(sub) = active_submodule %}
17366 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17367 {% else if super_scope_active %}
17368 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17369 {% else %}
17370 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17371 {% endif %}
17372 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17373 </div>
17374 </div>
17375 <a class="btn-back" href="/compare-scans">
17376 <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>
17377 Compare Scans
17378 </a>
17379 </div>
17380 {% if has_any_submodule_data %}
17381 <div class="submod-scope-bar">
17382 <span class="submod-scope-label">
17383 <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>
17384 Scope:
17385 </span>
17386 <div class="submod-scope-divider"></div>
17387 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17388 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
17389 title="All files — super-repo and all submodules combined">Full scan</a>
17390 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17391 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
17392 title="Only files that are not part of any submodule">Super-repo only</a>
17393 {% for sub in submodule_options %}
17394 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17395 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
17396 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17397 {% endfor %}
17398 </div>
17399 {% endif %}
17400 <div class="hero-body">
17401 <div class="meta-strip">
17402 <div class="delta-card delta-card-meta">
17403 <div class="meta-card-header">
17404 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17405 <div class="meta-card-project-col">
17406 <div class="meta-card-project">{{ project_name }}</div>
17407 {% if has_any_submodule_data %}
17408 {% if let Some(sub) = active_submodule %}
17409 <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>
17410 {% else if super_scope_active %}
17411 <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>
17412 {% else %}
17413 <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>
17414 {% endif %}
17415 {% endif %}
17416 </div>
17417 </div>
17418 {% if !baseline_git_commit.is_empty() %}
17419 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17420 {% else %}
17421 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17422 {% endif %}
17423 <div class="meta-card-rows">
17424 <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>
17425 <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>
17426 <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>
17427 <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>
17428 {% if let Some(tags) = baseline_git_tags %}
17429 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17430 {% endif %}
17431 </div>
17432 </div>
17433 <div class="delta-card delta-card-meta">
17434 <div class="meta-card-header">
17435 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17436 <div class="meta-card-project-col">
17437 <div class="meta-card-project">{{ project_name }}</div>
17438 {% if has_any_submodule_data %}
17439 {% if let Some(sub) = active_submodule %}
17440 <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>
17441 {% else if super_scope_active %}
17442 <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>
17443 {% else %}
17444 <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>
17445 {% endif %}
17446 {% endif %}
17447 </div>
17448 </div>
17449 {% if !current_git_commit.is_empty() %}
17450 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17451 {% else %}
17452 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17453 {% endif %}
17454 <div class="meta-card-rows">
17455 <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>
17456 <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>
17457 <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>
17458 <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>
17459 {% if let Some(tags) = current_git_tags %}
17460 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17461 {% endif %}
17462 </div>
17463 </div>
17464 </div>
17465 <div class="delta-strip">
17466 <div class="delta-card">
17467 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17468 <div class="delta-card-label">Code lines</div>
17469 <div class="delta-card-from">Before: {{ baseline_code }}</div>
17470 <div class="delta-card-to">{{ current_code }}</div>
17471 {% 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>
17472 {% 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>
17473 {% else %}<div class="delta-card-pct zero">±0%</div>
17474 {% endif %}
17475 </div>
17476 <div class="delta-card">
17477 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17478 <div class="delta-card-label">Files analyzed</div>
17479 <div class="delta-card-from">Before: {{ baseline_files }}</div>
17480 <div class="delta-card-to">{{ current_files }}</div>
17481 {% 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>
17482 {% 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>
17483 {% else %}<div class="delta-card-pct zero">±0%</div>
17484 {% endif %}
17485 </div>
17486 <div class="delta-card">
17487 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17488 <div class="delta-card-label">Comment lines</div>
17489 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17490 <div class="delta-card-to">{{ current_comments }}</div>
17491 {% 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>
17492 {% 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>
17493 {% else %}<div class="delta-card-pct zero">±0%</div>
17494 {% endif %}
17495 </div>
17496 <div class="delta-card delta-card-wide">
17497 <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>
17498 <div class="delta-card-label">File changes</div>
17499 <div class="file-changes-grid">
17500 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17501 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17502 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17503 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17504 </div>
17505 </div>
17506 </div>
17507 <div class="insights-panel">
17508 <div class="insight-card">
17509 <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>
17510 <div class="insight-label">Lines Added</div>
17511 <div class="insight-val pos">+{{ code_lines_added }}</div>
17512 <div class="insight-sub">New or grown source lines</div>
17513 </div>
17514 <div class="insight-card">
17515 <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>
17516 <div class="insight-label">Lines Removed</div>
17517 <div class="insight-val neg">−{{ code_lines_removed }}</div>
17518 <div class="insight-sub">Deleted or shrunk source lines</div>
17519 </div>
17520 <div class="insight-card">
17521 <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>
17522 <div class="insight-label">Churn Rate</div>
17523 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17524 <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>
17525 </div>
17526 {% if scope_flag %}
17527 <div class="insight-card insight-flag">
17528 <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>
17529 <div class="insight-label flag">Scope Signal</div>
17530 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17531 <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>
17532 </div>
17533 {% endif %}
17534 </div>
17535 </div>
17536 </section>
17537
17538 <section class="panel" id="inline-charts-section">
17539 <h2>Scan Delta Charts</h2>
17540 <div class="ic-grid">
17541 <div class="ic-card">
17542 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
17543 <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>
17544 <div id="ic-c1"></div>
17545 </div>
17546 <div class="ic-card" id="ic-lang-card">
17547 <div class="ic-card-h2">Language Code Delta</div>
17548 <div id="ic-c3"></div>
17549 </div>
17550 <div class="ic-card">
17551 <div class="ic-card-h2">Delta by Metric</div>
17552 <div id="ic-c2"></div>
17553 </div>
17554 <div class="ic-card">
17555 <div class="ic-card-h2">File Change Distribution</div>
17556 <div id="ic-c4"></div>
17557 </div>
17558 </div>
17559 </section>
17560
17561 <section class="panel">
17562 <h2>File-level delta</h2>
17563 <div class="filter-tabs-row">
17564 <div class="filter-tabs">
17565 <button class="tab-btn tab-all active" data-filter="all">All</button>
17566 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17567 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17568 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17569 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17570 </div>
17571 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17572 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
17573 <div class="export-group">
17574 <button type="button" class="btn-reset" id="delta-reset-btn">↻ Reset</button>
17575 <button type="button" class="export-btn" id="delta-csv-btn">
17576 <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>
17577 CSV
17578 </button>
17579 <button type="button" class="export-btn" id="delta-xls-btn">
17580 <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>
17581 Excel
17582 </button>
17583 <button type="button" class="export-btn" id="delta-charts-btn">
17584 <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>
17585 Charts
17586 </button>
17587 </div>
17588 </div>
17589 </div>
17590
17591 <div class="table-wrap">
17592 <table id="delta-table">
17593 <colgroup>
17594 <col>
17595 <col>
17596 <col>
17597 <col>
17598 <col>
17599 <col>
17600 <col>
17601 </colgroup>
17602 <thead>
17603 <tr id="delta-thead">
17604 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17605 <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>
17606 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17607 <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>
17608 <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>
17609 <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>
17610 <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>
17611 </tr>
17612 </thead>
17613 <tbody id="delta-tbody">
17614 {% for row in file_rows %}
17615 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17616 data-path="{{ row.relative_path }}"
17617 data-language="{{ row.language }}"
17618 data-baseline-code="{{ row.baseline_code }}"
17619 data-current-code="{{ row.current_code }}"
17620 data-code-delta="{{ row.code_delta_str }}"
17621 data-comment-delta="{{ row.comment_delta_str }}"
17622 data-total-delta="{{ row.total_delta_str }}"
17623 data-orig-idx="">
17624 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17625 <td class="hide-sm">{{ row.language }}</td>
17626 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17627 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17628 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17629 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17630 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17631 </tr>
17632 {% endfor %}
17633 </tbody>
17634 </table>
17635 </div>
17636 <div class="pagination">
17637 <span class="pagination-info" id="pg-info"></span>
17638 <div class="pagination-btns" id="pg-btns"></div>
17639 <div class="flex-row">
17640 <span class="per-page-label">Show</span>
17641 <select class="per-page" id="per-page-sel">
17642 <option value="10">10 per page</option>
17643 <option value="25" selected>25 per page</option>
17644 <option value="50">50 per page</option>
17645 <option value="100">100 per page</option>
17646 </select>
17647 <span class="per-page-label" id="pg-range-label"></span>
17648 </div>
17649 </div>
17650 </section>
17651 </div>
17652
17653 <div id="ic-tt"></div>
17654
17655 <footer class="site-footer">
17656 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
17657 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17658 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17659 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17660 · <a href="/api-docs" rel="noopener">REST API</a>
17661 </footer>
17662
17663 <script nonce="{{ csp_nonce }}">
17664 (function () {
17665 var storageKey = 'oxide-sloc-theme';
17666 var body = document.body;
17667 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17668 var toggle = document.getElementById('theme-toggle');
17669 if (toggle) toggle.addEventListener('click', function () {
17670 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17671 body.classList.toggle('dark-theme', next === 'dark');
17672 try { localStorage.setItem(storageKey, next); } catch(e) {}
17673 });
17674
17675 (function randomizeWatermarks() {
17676 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17677 if (!wms.length) return;
17678 var placed = [];
17679 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;}
17680 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];}
17681 var half=Math.floor(wms.length/2);
17682 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;});
17683 })();
17684
17685 (function spawnCodeParticles() {
17686 var container = document.getElementById('code-particles');
17687 if (!container) return;
17688 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'];
17689 for (var i = 0; i < 38; i++) {
17690 (function(idx) {
17691 var el = document.createElement('span');
17692 el.className = 'code-particle';
17693 el.textContent = snippets[idx % snippets.length];
17694 var left = Math.random() * 94 + 2;
17695 var top = Math.random() * 88 + 6;
17696 var dur = (Math.random() * 10 + 9).toFixed(1);
17697 var delay = (Math.random() * 18).toFixed(1);
17698 var rot = (Math.random() * 26 - 13).toFixed(1);
17699 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17700 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';
17701 container.appendChild(el);
17702 })(i);
17703 }
17704 })();
17705 })();
17706
17707 var activeStatusFilter = 'all';
17708 var deltaPerPage = 25, deltaCurrPage = 1;
17709
17710 function openFolder(path) {
17711 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17712 }
17713
17714 function getDeltaFilteredRows() {
17715 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17716 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17717 });
17718 }
17719
17720 function renderDeltaPage() {
17721 var filtered = getDeltaFilteredRows();
17722 var total = filtered.length;
17723 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17724 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17725 var start = (deltaCurrPage - 1) * deltaPerPage;
17726 var end = Math.min(start + deltaPerPage, total);
17727 var shownSet = {};
17728 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17729 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17730 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17731 });
17732 var rl = document.getElementById('pg-range-label');
17733 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17734 var info = document.getElementById('pg-info');
17735 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17736 var btns = document.getElementById('pg-btns');
17737 if (!btns) return;
17738 btns.innerHTML = '';
17739 if (totalPages <= 1) return;
17740 function makeBtn(lbl, pg, active, disabled) {
17741 var b = document.createElement('button');
17742 b.className = 'pg-btn' + (active ? ' active' : '');
17743 b.textContent = lbl; b.disabled = disabled;
17744 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17745 return b;
17746 }
17747 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17748 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17749 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17750 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17751 }
17752
17753 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17754
17755 function filterRows(status, btn) {
17756 activeStatusFilter = status;
17757 deltaCurrPage = 1;
17758 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17759 b.classList.remove('active');
17760 });
17761 if (btn) btn.classList.add('active');
17762 renderDeltaPage();
17763 }
17764
17765 // ── Sorting ──────────────────────────────────────────────────────────────
17766 var sortCol = null, sortOrder = 'asc';
17767 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17768 (function() {
17769 var tbody = document.getElementById('delta-tbody');
17770 if (!tbody) return;
17771 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17772 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17773 })();
17774
17775 function parseDeltaNum(str) {
17776 if (!str || str === '—') return 0;
17777 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17778 }
17779
17780 sortHeaders.forEach(function(th) {
17781 th.addEventListener('click', function(e) {
17782 if (e.target.classList.contains('col-resize-handle')) return;
17783 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17784 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17785 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17786 th.classList.add('sort-' + sortOrder);
17787 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17788 var tbody = document.getElementById('delta-tbody');
17789 if (!tbody) return;
17790 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17791 rows.sort(function(a, b) {
17792 var va, vb;
17793 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17794 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17795 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17796 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17797 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17798 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17799 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17800 else { va = ''; vb = ''; }
17801 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
17802 return va < vb ? 1 : va > vb ? -1 : 0;
17803 });
17804 rows.forEach(function(r) { tbody.appendChild(r); });
17805 deltaCurrPage = 1;
17806 renderDeltaPage();
17807 var activeBtn = document.querySelector('.tab-btn.active');
17808 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17809 if (activeBtn) activeBtn.classList.add('active');
17810 });
17811 });
17812
17813 // ── Column resize ─────────────────────────────────────────────────────────
17814 (function() {
17815 var table = document.getElementById('delta-table');
17816 if (!table) return;
17817 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17818 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
17819 ths.forEach(function(th, i) {
17820 var handle = th.querySelector('.col-resize-handle');
17821 if (!handle || !cols[i]) return;
17822 var startX, startW;
17823 handle.addEventListener('mousedown', function(e) {
17824 e.stopPropagation(); e.preventDefault();
17825 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17826 handle.classList.add('dragging');
17827 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17828 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17829 document.addEventListener('mousemove', onMove);
17830 document.addEventListener('mouseup', onUp);
17831 });
17832 });
17833 })();
17834
17835 // ── Reset ─────────────────────────────────────────────────────────────────
17836 window.resetDeltaTable = function() {
17837 sortCol = null; sortOrder = 'asc';
17838 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17839 var tbody = document.getElementById('delta-tbody');
17840 if (tbody) {
17841 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17842 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17843 rows.forEach(function(r) { tbody.appendChild(r); });
17844 }
17845 var table = document.getElementById('delta-table');
17846 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
17847 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
17848 activeStatusFilter = 'all';
17849 deltaCurrPage = 1;
17850 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17851 var allBtn = document.querySelector('.tab-btn');
17852 if (allBtn) allBtn.classList.add('active');
17853 renderDeltaPage();
17854 };
17855
17856 renderDeltaPage();
17857
17858 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
17859 (function() {
17860 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
17861 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
17862 });
17863 var resetBtn = document.getElementById('delta-reset-btn');
17864 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
17865 var csvBtn = document.getElementById('delta-csv-btn');
17866 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
17867 var xlsBtn = document.getElementById('delta-xls-btn');
17868 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
17869 var chartsBtn = document.getElementById('delta-charts-btn');
17870 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
17871 var ppSel = document.getElementById('per-page-sel');
17872 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
17873 var pathLink = document.getElementById('project-path-link');
17874 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
17875 })();
17876
17877 // ── Export helpers ────────────────────────────────────────────────────────
17878 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
17879 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
17880 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);}
17881 function slocMakeXlsx(fname,sd,dr){
17882 var enc=new TextEncoder();
17883 // CRC-32 table
17884 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;}
17885 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;}
17886 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
17887 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
17888 // Shared string table
17889 var ss=[],si={};
17890 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
17891 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
17892 // Worksheet builder — each WS() call gets its own row counter R
17893 function WS(){
17894 var R=0,buf=[];
17895 function cl(c){return String.fromCharCode(65+c);}
17896 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
17897 '<v>'+S(v)+'</v></c>';}
17898 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
17899 (st?' s="'+st+'"':'')+'>'+
17900 '<v>'+(+v)+'</v></c>';}
17901 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
17902 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17903 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
17904 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
17905 '<sheetFormatPr defaultRowHeight="15"/>'+
17906 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
17907 return{sc:sc,nc:nc,row:row,xml:xml};
17908 }
17909 // Language breakdown
17910 var lm={};
17911 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;});
17912 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
17913 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
17914 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
17915 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
17916 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17917 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17918 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):'';}
17919 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
17920 // Summary sheet
17921 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
17922 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
17923 r1(s1(0,proj,2));
17924 r1(s1(0,sd.bts+' → '+sd.cts,2));
17925 r1('');
17926 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
17927 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))));
17928 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))));
17929 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))));
17930 r1('');
17931 r1(s1(0,'FILE CHANGES',8));
17932 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
17933 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
17934 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
17935 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
17936 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
17937 if(langs.length){
17938 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
17939 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
17940 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)));});
17941 }
17942 r1('');r1(s1(0,'SCAN METADATA',8));
17943 r1(s1(1,_blabel)+s1(2,_clabel));
17944 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
17945 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
17946 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"/>');
17947 // File Delta sheet
17948 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
17949 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));
17950 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)));});
17951 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
17952 // Shared strings XML
17953 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17954 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
17955 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
17956 // XLSX file map
17957 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
17958 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>',
17959 '_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>',
17960 '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>',
17961 '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>',
17962 '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>',
17963 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
17964 // ZIP packer — STORED (no compression), compatible with all XLSX readers
17965 var zparts=[],zcds=[],zoff=0,znf=0;
17966 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
17967 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
17968 ].forEach(function(name){
17969 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
17970 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]);
17971 var entry=new Uint8Array(lha.length+nb.length+sz);
17972 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
17973 zparts.push(entry);
17974 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));
17975 var cde=new Uint8Array(cda.length+nb.length);
17976 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
17977 zcds.push(cde);zoff+=entry.length;znf++;
17978 });
17979 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
17980 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]);
17981 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
17982 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
17983 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
17984 zout.set(new Uint8Array(ea),zpos);
17985 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
17986 var xurl=URL.createObjectURL(xblob);
17987 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
17988 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
17989 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
17990 }
17991 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;');}
17992 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
17993 function getExportFilename(ext){return _exportBase+'.'+ext;}
17994
17995 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 }}'};
17996 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;}
17997 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
17998 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
17999 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18000 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18001 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):'';}
18002 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
18003 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)]];}
18004 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
18005 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;}
18006 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
18007 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
18008
18009 // ── Chart HTML report ─────────────────────────────────────────────────────
18010 function slocChartReport(fname, sd, dr) {
18011 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
18012 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18013 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18014 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();}
18015 function px(n){return Math.round(n);}
18016 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
18017 // Language map
18018 var lm={};
18019 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;});
18020 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18021
18022 // Builds onmouse* attrs for interactive tooltip on each SVG element
18023 function barTT(label,val){
18024 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18025 }
18026
18027 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18028 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'}];
18029 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18030 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18031 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18032 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18033 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"/>';}
18034 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18035 c1mets.forEach(function(m,i){
18036 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18037 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18038 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>';
18039 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))+'/>';
18040 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>';
18041 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))+'/>';
18042 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>';
18043 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>';
18044 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>';
18045 });
18046 c1+='</svg>';
18047
18048 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18049 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'}];
18050 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18051 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18052 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18053 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18054 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18055 mets.forEach(function(m,i){
18056 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18057 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18058 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18059 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>';
18060 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18061 if(bw>=52){
18062 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>';
18063 }else{
18064 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18065 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>';
18066 }
18067 });
18068 c2+='</svg>';
18069
18070 // ── Chart 3: Language Code Delta ─────────────────────────────────────
18071 var c3='';
18072 if(langs.length){
18073 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18074 var C3W=550,c3LW=124,c3FW=52;
18075 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18076 var L3rH=30,C3H=langs.length*L3rH+20;
18077 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18078 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18079 langs.forEach(function(l,i){
18080 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18081 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18082 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18083 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18084 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':''))+'/>';
18085 if(bw>=48){
18086 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>';
18087 }else{
18088 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18089 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>';
18090 }
18091 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>';
18092 });
18093 c3+='</svg>';
18094 }
18095
18096 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18097 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;});
18098 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18099 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18100 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18101 var ang=-Math.PI/2;
18102 segs.forEach(function(s){
18103 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18104 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18105 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18106 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18107 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18108 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)+'%')+'/>';
18109 ang+=sw;
18110 });
18111 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>';
18112 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18113 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>';});
18114 c4+='</svg>';
18115
18116 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18117 var ttJs='var tt=document.getElementById("ox-tt");'+
18118 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18119 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18120 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18121 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18122 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18123 'function oxHT(){tt.style.display="none";}';
18124
18125 // body max-width keeps charts from inflating beyond design dimensions on
18126 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18127 // each chart's height blows up proportionally, breaking the one-page layout.
18128 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;}'+
18129 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18130 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18131 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18132 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18133 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18134 'svg{display:block;}'+
18135 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18136 '#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;}'+
18137 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18138 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18139 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18140 '<div id="ox-tt"><\/div>'+
18141 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
18142 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
18143 '<div class="two-col">'+
18144 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
18145 '<div class="leg">'+
18146 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18147 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18148 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18149 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
18150 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18151 '<\/div>'+
18152 '<div class="two-col">'+
18153 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18154 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18155 '<\/div>'+
18156 '<script>'+ttJs+'<\/script>'+
18157 '<\/body><\/html>';
18158 slocDownload(html, fname, 'text/html;charset=utf-8;');
18159 }
18160 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18161 // ── Inline delta charts ────────────────────────────────────────────────────
18162 var _icTT=document.getElementById('ic-tt');
18163 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18164 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';};
18165 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18166 (function(){
18167 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18168 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18169 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();}
18170 function px(n){return Math.round(n);}
18171 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18172 function btt(l,v){return ' class="ic-cb" onmouseover="icTT(event,\''+jsq(l)+'\',\''+jsq(v)+'\')" onmouseout="icHT()" onmousemove="icMT(event)"';}
18173 var dr=getDeltaExportRows(),sd=_sd,lm={};
18174 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;});
18175 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18176 // Chart 1: Baseline vs Current grouped bars
18177 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'}];
18178 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18179 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;
18180 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18181 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"/>';}
18182 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18183 c1mets.forEach(function(m,i){
18184 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18185 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18186 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>';
18187 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"/>';
18188 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>';
18189 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"/>';
18190 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>';
18191 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>';
18192 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>';
18193 });
18194 c1+='</svg>';
18195 // Chart 2: Delta by Metric
18196 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'}];
18197 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18198 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;
18199 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18200 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18201 mets.forEach(function(m,i){
18202 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);
18203 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>';
18204 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18205 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>';}
18206 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>';}
18207 });
18208 c2+='</svg>';
18209 // Chart 3: Language Code Delta
18210 var c3='';
18211 if(langs.length){
18212 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18213 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;
18214 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18215 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18216 langs.forEach(function(l,i){
18217 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);
18218 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18219 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"/>';
18220 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>';}
18221 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>';}
18222 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>';
18223 });
18224 c3+='</svg>';
18225 }
18226 // Chart 4: File Change Donut
18227 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;});
18228 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18229 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;
18230 if(segs.length===1){
18231 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18232 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18233 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18234 } else {
18235 segs.forEach(function(s){
18236 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18237 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);
18238 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);
18239 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"/>';
18240 ang+=sw;
18241 });
18242 }
18243 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>';
18244 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18245 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>';});
18246 c4+='</svg>';
18247 var e1=document.getElementById('ic-c1');if(e1)e1.innerHTML=c1;
18248 var e2=document.getElementById('ic-c2');if(e2)e2.innerHTML=c2;
18249 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>';
18250 var e4=document.getElementById('ic-c4');if(e4)e4.innerHTML=c4;
18251 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18252 })();
18253 </script>
18254 <script nonce="{{ csp_nonce }}">
18255 (function(){
18256 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'}];
18257 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);});}
18258 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18259 function init(){
18260 var btn=document.getElementById('settings-btn');if(!btn)return;
18261 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18262 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>';
18263 document.body.appendChild(m);
18264 var g=document.getElementById('scheme-grid');
18265 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);});
18266 var cl=document.getElementById('settings-close');
18267 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);
18268 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');});
18269 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18270 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18271 }
18272 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18273 }());
18274 </script>
18275</body>
18276</html>
18277"##,
18278 ext = "html"
18279)]
18280#[allow(clippy::struct_excessive_bools)]
18282struct CompareTemplate {
18283 version: &'static str,
18284 project_label: String,
18285 baseline_git_commit: String,
18286 current_git_commit: String,
18287 baseline_run_id: String,
18288 current_run_id: String,
18289 baseline_run_id_short: String,
18290 current_run_id_short: String,
18291 baseline_timestamp: String,
18292 baseline_timestamp_utc_ms: i64,
18293 current_timestamp: String,
18294 current_timestamp_utc_ms: i64,
18295 project_path: String,
18296 baseline_code: u64,
18297 current_code: u64,
18298 code_lines_delta_str: String,
18299 code_lines_delta_class: String,
18300 baseline_files: u64,
18301 current_files: u64,
18302 files_analyzed_delta_str: String,
18303 files_analyzed_delta_class: String,
18304 baseline_comments: u64,
18305 current_comments: u64,
18306 comment_lines_delta_str: String,
18307 comment_lines_delta_class: String,
18308 code_lines_pct_str: String,
18309 files_analyzed_pct_str: String,
18310 comment_lines_pct_str: String,
18311 code_lines_added: i64,
18312 code_lines_removed: i64,
18313 new_scope: bool,
18315 churn_rate_str: String,
18316 churn_rate_class: String,
18317 scope_flag: bool,
18318 files_added: usize,
18319 files_removed: usize,
18320 files_modified: usize,
18321 files_unchanged: usize,
18322 file_rows: Vec<CompareFileDeltaRow>,
18323 baseline_git_author: Option<String>,
18324 current_git_author: Option<String>,
18325 baseline_git_branch: String,
18326 current_git_branch: String,
18327 baseline_git_tags: Option<String>,
18328 current_git_tags: Option<String>,
18329 baseline_git_commit_date: Option<String>,
18330 current_git_commit_date: Option<String>,
18331 project_name: String,
18332 submodule_options: Vec<String>,
18334 has_any_submodule_data: bool,
18336 active_submodule: Option<String>,
18338 super_scope_active: bool,
18340 csp_nonce: String,
18341}
18342
18343#[derive(Template)]
18346#[template(
18347 source = r##"
18348<!doctype html>
18349<html lang="en">
18350<head>
18351 <meta charset="utf-8">
18352 <meta name="viewport" content="width=device-width, initial-scale=1">
18353 <title>OxideSLOC | Sign In</title>
18354 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18355 <style nonce="{{ csp_nonce }}">
18356 :root {
18357 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18358 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18359 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18360 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18361 }
18362 *{box-sizing:border-box;}
18363 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);}
18364 .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);}
18365 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18366 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18367 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18368 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18369 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18370 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18371 .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;}
18372 @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));}}
18373 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18374 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18375 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18376 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18377 .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;}
18378 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18379 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;}
18380 input[type=password]:focus{border-color:var(--oxide);}
18381 .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;}
18382 .btn:hover{opacity:.88;}
18383 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18384 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18385 </style>
18386</head>
18387<body>
18388 <div class="background-watermarks" aria-hidden="true">
18389 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18390 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18391 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18393 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18394 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18395 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18396 </div>
18397 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18398<nav class="top-nav">
18399 <a class="brand" href="/">
18400 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18401 <span class="brand-title">OxideSLOC</span>
18402 </a>
18403</nav>
18404<main class="page">
18405 <div class="card">
18406 <h1>Sign In</h1>
18407 <p class="subtitle">Enter the API key printed when the server started.</p>
18408 {% if has_error %}
18409 <div class="error">Incorrect API key — please try again.</div>
18410 {% endif %}
18411 <form method="POST" action="/auth/login">
18412 <input type="hidden" name="next" value="{{ next_url|e }}">
18413 <label for="key">API Key</label>
18414 <input id="key" type="password" name="key" autocomplete="current-password"
18415 placeholder="Paste your API key here" autofocus>
18416 <button type="submit" class="btn">Sign In</button>
18417 </form>
18418 <p class="hint">
18419 The API key was printed in the terminal when the server started.<br>
18420 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18421 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18422 </p>
18423 </div>
18424</main>
18425<script nonce="{{ csp_nonce }}">
18426(function() {
18427 (function randomizeWatermarks() {
18428 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18429 if (!wms.length) return;
18430 var placed = [];
18431 function tooClose(top, left) {
18432 for (var i = 0; i < placed.length; i++) {
18433 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18434 if (dt < 16 && dl < 12) return true;
18435 }
18436 return false;
18437 }
18438 function pick(leftBand) {
18439 for (var attempt = 0; attempt < 50; attempt++) {
18440 var top = Math.random() * 88 + 2;
18441 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18442 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18443 }
18444 var top = Math.random() * 88 + 2;
18445 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18446 placed.push([top, left]); return [top, left];
18447 }
18448 var half = Math.floor(wms.length / 2);
18449 wms.forEach(function (img, i) {
18450 var pos = pick(i < half);
18451 var size = Math.floor(Math.random() * 100 + 120);
18452 var rot = (Math.random() * 360).toFixed(1);
18453 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18454 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;
18455 });
18456 })();
18457 (function spawnCodeParticles() {
18458 var container = document.getElementById('code-particles');
18459 if (!container) return;
18460 var snippets = [
18461 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18462 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18463 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18464 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18465 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18466 ];
18467 var count = 38;
18468 for (var i = 0; i < count; i++) {
18469 (function(idx) {
18470 var el = document.createElement('span');
18471 el.className = 'code-particle';
18472 el.textContent = snippets[idx % snippets.length];
18473 var left = Math.random() * 94 + 2;
18474 var top = Math.random() * 88 + 6;
18475 var dur = (Math.random() * 10 + 9).toFixed(1);
18476 var delay = (Math.random() * 18).toFixed(1);
18477 var rot = (Math.random() * 26 - 13).toFixed(1);
18478 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18479 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18480 container.appendChild(el);
18481 })(i);
18482 }
18483 })();
18484})();
18485</script>
18486</body>
18487</html>
18488"##,
18489 ext = "html"
18490)]
18491struct LoginTemplate {
18492 csp_nonce: String,
18493 has_error: bool,
18494 next_url: String,
18495 lockout_threshold: u32,
18496}
18497
18498#[derive(Template)]
18501#[template(
18502 source = r##"
18503<!doctype html>
18504<html lang="en">
18505<head>
18506 <meta charset="utf-8">
18507 <meta name="viewport" content="width=device-width, initial-scale=1">
18508 <title>OxideSLOC — REST API Reference</title>
18509 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18510 <style nonce="{{ csp_nonce }}">
18511 :root {
18512 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18513 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18514 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18515 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18516 --success:#16a34a;
18517 }
18518 body.dark-theme {
18519 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18520 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18521 }
18522 *{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);}
18523 .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);}
18524 .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;}
18525 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18526 .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));}
18527 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18528 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18529 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18530 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18531 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18532 @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; } }
18533 .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;}
18534 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18535 .nav-pill.active{background:rgba(255,255,255,0.22);}
18536 .nav-dropdown{position:relative;display:inline-flex;}
18537 .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;}
18538 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18539 .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;}
18540 .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;}
18541 .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);}
18542 .nav-dropdown-menu a:last-child{border-bottom:none;}
18543 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18544 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18545 .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;}
18546 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18547 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18548 .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;}
18549 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18550 .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);}
18551 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18552 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18553 .settings-modal-body{padding:14px 16px 16px;}
18554 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18555 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18556 .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;}
18557 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18558 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18559 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18560 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18561 .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;}
18562 .tz-select:focus{border-color:var(--oxide);}
18563 .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18564 .page-header{margin-bottom:28px;}
18565 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18566 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18567 .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;}
18568 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18569 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18570 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18571 .callout strong{font-weight:800;}
18572 .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;}
18573 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18574 .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;}
18575 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18576 .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;}
18577 body.dark-theme .base-url-value{color:var(--accent);}
18578 .section{margin-bottom:36px;}
18579 .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);}
18580 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18581 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18582 .ep-header:hover{background:var(--surface-2);}
18583 .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;}
18584 .method.get{background:#dcfce7;color:#166534;}
18585 .method.post{background:#dbeafe;color:#1e40af;}
18586 .method.delete{background:#fee2e2;color:#991b1b;}
18587 body.dark-theme .method.get{background:#14532d;color:#86efac;}
18588 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18589 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18590 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18591 .ep-path .param{color:var(--oxide-2);}
18592 body.dark-theme .ep-path .param{color:var(--oxide);}
18593 .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;}
18594 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18595 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18596 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18597 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18598 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18599 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18600 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18601 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18602 .ep-card.open .chevron{transform:rotate(180deg);}
18603 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18604 .ep-card.open .ep-body{display:block;}
18605 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18606 .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;}
18607 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18608 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18609 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18610 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18611 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);}
18612 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18613 table.params tr:last-child td{border-bottom:none;}
18614 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18615 .pt-type{color:var(--muted-2);font-size:12px;}
18616 .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;}
18617 .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;}
18618 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18619 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18620 details.schema{margin-bottom:14px;}
18621 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;}
18622 details.schema summary:hover{color:var(--text);}
18623 .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;}
18624 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18625 .curl-wrap{position:relative;}
18626 .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;}
18627 .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;}
18628 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18629 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18630 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18631 .webhook-note a{color:var(--accent-2);text-decoration:none;}
18632 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18633 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18634 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18635 .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;}
18636 @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));}}
18637 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18638 .site-footer a{color:var(--muted);}
18639 </style>
18640</head>
18641<body>
18642 <div class="background-watermarks" aria-hidden="true">
18643 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18644 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18645 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18646 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18647 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18648 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18649 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18650 </div>
18651 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18652 <div class="top-nav">
18653 <div class="top-nav-inner">
18654 <a class="brand" href="/">
18655 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18656 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18657 </a>
18658 <div class="nav-right">
18659 <a class="nav-pill" href="/">Home</a>
18660 <div class="nav-dropdown">
18661 <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>
18662 <div class="nav-dropdown-menu">
18663 <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>
18664 </div>
18665 </div>
18666 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18667 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18668 <div class="nav-dropdown">
18669 <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>
18670 <div class="nav-dropdown-menu">
18671 <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>
18672 </div>
18673 </div>
18674 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18675 <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>
18676 </button>
18677 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18678 <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>
18679 <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>
18680 </button>
18681 </div>
18682 </div>
18683 </div>
18684
18685 <div class="page">
18686 <div class="page-header">
18687 <h1 class="page-title">REST API Reference</h1>
18688 <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>
18689 </div>
18690
18691 {% if has_api_key %}
18692 <div class="callout key-set">
18693 <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>
18694 <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>
18695 </div>
18696 {% else %}
18697 <div class="callout no-key">
18698 <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>
18699 <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>
18700 </div>
18701 {% endif %}
18702
18703 <div class="base-url-bar">
18704 <span class="base-url-label">Base URL</span>
18705 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18706 </div>
18707
18708 <!-- Health -->
18709 <div class="section">
18710 <h2 class="section-title">Health & Status</h2>
18711 <div class="ep-card">
18712 <div class="ep-header">
18713 <span class="method get">GET</span>
18714 <span class="ep-path">/healthz</span>
18715 <span class="auth-badge public">Public</span>
18716 <span class="ep-desc">Server liveness check</span>
18717 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18718 </div>
18719 <div class="ep-body">
18720 <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>
18721 <p class="params-heading">Response</p>
18722 <div class="schema-block">200 OK
18723Content-Type: text/plain
18724
18725ok</div>
18726 <p class="curl-heading">Example</p>
18727 <div class="curl-wrap">
18728 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18729 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18730 </div>
18731 </div>
18732 </div>
18733 </div>
18734
18735 <!-- Badges -->
18736 <div class="section">
18737 <h2 class="section-title">Badges</h2>
18738 <div class="ep-card">
18739 <div class="ep-header">
18740 <span class="method get">GET</span>
18741 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18742 <span class="auth-badge public">Public</span>
18743 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18744 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18745 </div>
18746 <div class="ep-body">
18747 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18748 <p class="params-heading">Path Parameters</p>
18749 <table class="params">
18750 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18751 <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>
18752 </table>
18753 <p class="curl-heading">Example</p>
18754 <div class="curl-wrap">
18755 <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>
18756 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18757 </div>
18758 </div>
18759 </div>
18760 </div>
18761
18762 <!-- Metrics -->
18763 <div class="section">
18764 <h2 class="section-title">Metrics</h2>
18765
18766 <div class="ep-card">
18767 <div class="ep-header">
18768 <span class="method get">GET</span>
18769 <span class="ep-path">/api/metrics/latest</span>
18770 <span class="auth-badge protected">Protected</span>
18771 <span class="ep-desc">Latest scan metrics (JSON)</span>
18772 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18773 </div>
18774 <div class="ep-body">
18775 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18776 <details class="schema"><summary>Response schema</summary>
18777<div class="schema-block">{
18778 "run_id": string, // UUID
18779 "timestamp": string, // ISO-8601 UTC
18780 "project": string, // scanned root path
18781 "summary": {
18782 "files_analyzed": number,
18783 "files_skipped": number,
18784 "code_lines": number,
18785 "comment_lines": number,
18786 "blank_lines": number,
18787 "total_physical_lines": number,
18788 "functions": number,
18789 "classes": number,
18790 "variables": number,
18791 "imports": number
18792 },
18793 "languages": [
18794 { "name": string, "files": number, "code_lines": number,
18795 "comment_lines": number, "blank_lines": number,
18796 "functions": number, "classes": number,
18797 "variables": number, "imports": number }
18798 ]
18799}</div></details>
18800 <p class="curl-heading">Example</p>
18801 <div class="curl-wrap">
18802 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18803 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
18804 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
18805 </div>
18806 </div>
18807 </div>
18808
18809 <div class="ep-card">
18810 <div class="ep-header">
18811 <span class="method get">GET</span>
18812 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
18813 <span class="auth-badge protected">Protected</span>
18814 <span class="ep-desc">Metrics for a specific run</span>
18815 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18816 </div>
18817 <div class="ep-body">
18818 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
18819 <p class="params-heading">Path Parameters</p>
18820 <table class="params">
18821 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18822 <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>
18823 </table>
18824 <p class="curl-heading">Example</p>
18825 <div class="curl-wrap">
18826 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18827 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
18828 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
18829 </div>
18830 </div>
18831 </div>
18832
18833 <div class="ep-card">
18834 <div class="ep-header">
18835 <span class="method get">GET</span>
18836 <span class="ep-path">/api/metrics/history</span>
18837 <span class="auth-badge protected">Protected</span>
18838 <span class="ep-desc">Paginated scan history</span>
18839 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18840 </div>
18841 <div class="ep-body">
18842 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
18843 <p class="params-heading">Query Parameters</p>
18844 <table class="params">
18845 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18846 <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>
18847 <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>
18848 </table>
18849 <details class="schema"><summary>Response schema</summary>
18850<div class="schema-block">[{
18851 "run_id": string,
18852 "timestamp": string, // ISO-8601 UTC
18853 "commit": string | null,
18854 "branch": string | null,
18855 "tags": string[],
18856 "code_lines": number,
18857 "comment_lines": number,
18858 "blank_lines": number,
18859 "physical_lines": number,
18860 "files_analyzed": number,
18861 "project_label": string,
18862 "html_url": string | null
18863}]</div></details>
18864 <p class="curl-heading">Example</p>
18865 <div class="curl-wrap">
18866 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18867 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
18868 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
18869 </div>
18870 </div>
18871 </div>
18872
18873 <div class="ep-card">
18874 <div class="ep-header">
18875 <span class="method get">GET</span>
18876 <span class="ep-path">/api/project-history</span>
18877 <span class="auth-badge protected">Protected</span>
18878 <span class="ep-desc">Project-level scan summary</span>
18879 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18880 </div>
18881 <div class="ep-body">
18882 <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>
18883 <p class="params-heading">Query Parameters</p>
18884 <table class="params">
18885 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18886 <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>
18887 </table>
18888 <details class="schema"><summary>Response schema</summary>
18889<div class="schema-block">{
18890 "scan_count": number,
18891 "last_scan_id": string | null,
18892 "last_scan_timestamp": string | null, // ISO-8601
18893 "last_scan_code_lines": number | null,
18894 "last_git_branch": string | null,
18895 "last_git_commit": string | null
18896}</div></details>
18897 <p class="curl-heading">Example</p>
18898 <div class="curl-wrap">
18899 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18900 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
18901 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
18902 </div>
18903 </div>
18904 </div>
18905
18906 <div class="ep-card">
18907 <div class="ep-header">
18908 <span class="method get">GET</span>
18909 <span class="ep-path">/api/metrics/submodules</span>
18910 <span class="auth-badge protected">Protected</span>
18911 <span class="ep-desc">List known git submodules across scans</span>
18912 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18913 </div>
18914 <div class="ep-body">
18915 <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>
18916 <p class="params-heading">Query Parameters</p>
18917 <table class="params">
18918 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18919 <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>
18920 </table>
18921 <details class="schema"><summary>Response schema</summary>
18922<div class="schema-block">[{
18923 "name": string, // submodule name
18924 "relative_path": string // path relative to the project root
18925}]</div></details>
18926 <p class="curl-heading">Example</p>
18927 <div class="curl-wrap">
18928 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18929 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
18930 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
18931 </div>
18932 </div>
18933 </div>
18934 </div>
18935
18936 <!-- Async Run Status -->
18937 <div class="section">
18938 <h2 class="section-title">Async Run Status</h2>
18939
18940 <div class="ep-card">
18941 <div class="ep-header">
18942 <span class="method get">GET</span>
18943 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
18944 <span class="auth-badge protected">Protected</span>
18945 <span class="ep-desc">Poll scan completion</span>
18946 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18947 </div>
18948 <div class="ep-body">
18949 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
18950 <details class="schema"><summary>Response schema</summary>
18951<div class="schema-block">// Running
18952{ "state": "running", "elapsed_secs": number }
18953
18954// Complete
18955{ "state": "complete", "run_id": string }
18956
18957// Failed
18958{ "state": "failed", "message": string }</div></details>
18959 <p class="curl-heading">Example</p>
18960 <div class="curl-wrap">
18961 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18962 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
18963 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
18964 </div>
18965 </div>
18966 </div>
18967
18968 <div class="ep-card">
18969 <div class="ep-header">
18970 <span class="method get">GET</span>
18971 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
18972 <span class="auth-badge protected">Protected</span>
18973 <span class="ep-desc">Poll PDF generation readiness</span>
18974 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18975 </div>
18976 <div class="ep-body">
18977 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
18978 <details class="schema"><summary>Response schema</summary>
18979<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
18980 <p class="curl-heading">Example</p>
18981 <div class="curl-wrap">
18982 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18983 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
18984 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
18985 </div>
18986 </div>
18987 </div>
18988
18989 <div class="ep-card">
18990 <div class="ep-header">
18991 <span class="method post">POST</span>
18992 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
18993 <span class="auth-badge protected">Protected</span>
18994 <span class="ep-desc">Cancel a running scan</span>
18995 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18996 </div>
18997 <div class="ep-body">
18998 <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>
18999 <p class="curl-heading">Example</p>
19000 <div class="curl-wrap">
19001 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
19002 -H "Authorization: Bearer $SLOC_API_KEY" \
19003 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
19004 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
19005 </div>
19006 </div>
19007 </div>
19008 </div>
19009
19010 <!-- Scan Profiles -->
19011 <div class="section">
19012 <h2 class="section-title">Scan Profiles</h2>
19013
19014 <div class="ep-card">
19015 <div class="ep-header">
19016 <span class="method get">GET</span>
19017 <span class="ep-path">/api/scan-profiles</span>
19018 <span class="auth-badge protected">Protected</span>
19019 <span class="ep-desc">List saved scan profiles</span>
19020 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19021 </div>
19022 <div class="ep-body">
19023 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19024 <details class="schema"><summary>Response schema</summary>
19025<div class="schema-block">{
19026 "profiles": [{
19027 "id": string, // UUID
19028 "name": string,
19029 "created_at": string, // ISO-8601
19030 "params": object
19031 }]
19032}</div></details>
19033 <p class="curl-heading">Example</p>
19034 <div class="curl-wrap">
19035 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19036 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19037 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19038 </div>
19039 </div>
19040 </div>
19041
19042 <div class="ep-card">
19043 <div class="ep-header">
19044 <span class="method post">POST</span>
19045 <span class="ep-path">/api/scan-profiles</span>
19046 <span class="auth-badge protected">Protected</span>
19047 <span class="ep-desc">Save a scan profile</span>
19048 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19049 </div>
19050 <div class="ep-body">
19051 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19052 <p class="params-heading">Request Body (application/json)</p>
19053 <table class="params">
19054 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19055 <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>
19056 <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>
19057 </table>
19058 <details class="schema"><summary>Response schema</summary>
19059<div class="schema-block">{ "ok": true }</div></details>
19060 <p class="curl-heading">Example</p>
19061 <div class="curl-wrap">
19062 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19063 -H "Authorization: Bearer $SLOC_API_KEY" \
19064 -H "Content-Type: application/json" \
19065 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19066 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19067 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19068 </div>
19069 </div>
19070 </div>
19071
19072 <div class="ep-card">
19073 <div class="ep-header">
19074 <span class="method delete">DELETE</span>
19075 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19076 <span class="auth-badge protected">Protected</span>
19077 <span class="ep-desc">Delete a scan profile</span>
19078 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19079 </div>
19080 <div class="ep-body">
19081 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19082 <p class="params-heading">Path Parameters</p>
19083 <table class="params">
19084 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19085 <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>
19086 </table>
19087 <details class="schema"><summary>Response schema</summary>
19088<div class="schema-block">{ "ok": true }</div></details>
19089 <p class="curl-heading">Example</p>
19090 <div class="curl-wrap">
19091 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19092 -H "Authorization: Bearer $SLOC_API_KEY" \
19093 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
19094 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19095 </div>
19096 </div>
19097 </div>
19098 </div>
19099
19100 <!-- Scheduled Scans -->
19101 <div class="section">
19102 <h2 class="section-title">Scheduled Scans</h2>
19103
19104 <div class="ep-card">
19105 <div class="ep-header">
19106 <span class="method get">GET</span>
19107 <span class="ep-path">/api/schedules</span>
19108 <span class="auth-badge protected">Protected</span>
19109 <span class="ep-desc">List configured schedules</span>
19110 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19111 </div>
19112 <div class="ep-body">
19113 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19114 <p class="curl-heading">Example</p>
19115 <div class="curl-wrap">
19116 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
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-list">Copy</button>
19119 </div>
19120 </div>
19121 </div>
19122
19123 <div class="ep-card">
19124 <div class="ep-header">
19125 <span class="method post">POST</span>
19126 <span class="ep-path">/api/schedules</span>
19127 <span class="auth-badge protected">Protected</span>
19128 <span class="ep-desc">Create 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">Creates a new scheduled scan. Use the <a href="/integrations">Integrations UI</a> to configure the full field set interactively.</p>
19133 <p class="curl-heading">Example</p>
19134 <div class="curl-wrap">
19135 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19136 -H "Authorization: Bearer $SLOC_API_KEY" \
19137 -H "Content-Type: application/json" \
19138 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
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-create">Copy</button>
19141 </div>
19142 </div>
19143 </div>
19144
19145 <div class="ep-card">
19146 <div class="ep-header">
19147 <span class="method delete">DELETE</span>
19148 <span class="ep-path">/api/schedules</span>
19149 <span class="auth-badge protected">Protected</span>
19150 <span class="ep-desc">Delete a schedule</span>
19151 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19152 </div>
19153 <div class="ep-body">
19154 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19155 <p class="curl-heading">Example</p>
19156 <div class="curl-wrap">
19157 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19158 -H "Authorization: Bearer $SLOC_API_KEY" \
19159 -H "Content-Type: application/json" \
19160 -d '{"id":"<schedule_id>"}' \
19161 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19162 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19163 </div>
19164 </div>
19165 </div>
19166 </div>
19167
19168 <!-- Git Browser -->
19169 <div class="section">
19170 <h2 class="section-title">Git Browser</h2>
19171
19172 <div class="ep-card">
19173 <div class="ep-header">
19174 <span class="method get">GET</span>
19175 <span class="ep-path">/api/git/refs</span>
19176 <span class="auth-badge protected">Protected</span>
19177 <span class="ep-desc">List git refs for a repository</span>
19178 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19179 </div>
19180 <div class="ep-body">
19181 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19182 <p class="params-heading">Query Parameters</p>
19183 <table class="params">
19184 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19185 <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>
19186 </table>
19187 <p class="curl-heading">Example</p>
19188 <div class="curl-wrap">
19189 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19190 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19191 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19192 </div>
19193 </div>
19194 </div>
19195
19196 <div class="ep-card">
19197 <div class="ep-header">
19198 <span class="method get">GET</span>
19199 <span class="ep-path">/api/git/scan-ref</span>
19200 <span class="auth-badge protected">Protected</span>
19201 <span class="ep-desc">SLOC-scan a specific git ref</span>
19202 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19203 </div>
19204 <div class="ep-body">
19205 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19206 <p class="params-heading">Query Parameters</p>
19207 <table class="params">
19208 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19209 <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>
19210 <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>
19211 </table>
19212 <p class="curl-heading">Example</p>
19213 <div class="curl-wrap">
19214 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19215 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
19216 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19217 </div>
19218 </div>
19219 </div>
19220
19221 <div class="ep-card">
19222 <div class="ep-header">
19223 <span class="method get">GET</span>
19224 <span class="ep-path">/api/git/compare-refs</span>
19225 <span class="auth-badge protected">Protected</span>
19226 <span class="ep-desc">Compare SLOC across two git refs</span>
19227 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19228 </div>
19229 <div class="ep-body">
19230 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19231 <p class="params-heading">Query Parameters</p>
19232 <table class="params">
19233 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19234 <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>
19235 <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>
19236 <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>
19237 </table>
19238 <p class="curl-heading">Example</p>
19239 <div class="curl-wrap">
19240 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19241 "<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>
19242 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19243 </div>
19244 </div>
19245 </div>
19246 </div>
19247
19248 <!-- Webhooks -->
19249 <div class="section">
19250 <h2 class="section-title">Webhooks</h2>
19251 <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>
19252
19253 <div class="ep-card">
19254 <div class="ep-header">
19255 <span class="method post">POST</span>
19256 <span class="ep-path">/webhooks/github</span>
19257 <span class="auth-badge hmac">HMAC</span>
19258 <span class="ep-desc">GitHub push event receiver</span>
19259 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19260 </div>
19261 <div class="ep-body">
19262 <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>
19263 <p class="params-heading">Required Headers</p>
19264 <table class="params">
19265 <tr><th>Header</th><th>Value</th></tr>
19266 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19267 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19268 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19269 </table>
19270 </div>
19271 </div>
19272
19273 <div class="ep-card">
19274 <div class="ep-header">
19275 <span class="method post">POST</span>
19276 <span class="ep-path">/webhooks/gitlab</span>
19277 <span class="auth-badge hmac">HMAC</span>
19278 <span class="ep-desc">GitLab push event receiver</span>
19279 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19280 </div>
19281 <div class="ep-body">
19282 <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>
19283 <p class="params-heading">Required Headers</p>
19284 <table class="params">
19285 <tr><th>Header</th><th>Value</th></tr>
19286 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19287 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19288 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19289 </table>
19290 </div>
19291 </div>
19292
19293 <div class="ep-card">
19294 <div class="ep-header">
19295 <span class="method post">POST</span>
19296 <span class="ep-path">/webhooks/bitbucket</span>
19297 <span class="auth-badge hmac">HMAC</span>
19298 <span class="ep-desc">Bitbucket push event receiver</span>
19299 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19300 </div>
19301 <div class="ep-body">
19302 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19303 <p class="params-heading">Required Headers</p>
19304 <table class="params">
19305 <tr><th>Header</th><th>Value</th></tr>
19306 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19307 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19308 </table>
19309 </div>
19310 </div>
19311 </div>
19312
19313 <!-- Config -->
19314 <div class="section">
19315 <h2 class="section-title">Config Import / Export</h2>
19316
19317 <div class="ep-card">
19318 <div class="ep-header">
19319 <span class="method get">GET</span>
19320 <span class="ep-path">/export-config</span>
19321 <span class="auth-badge protected">Protected</span>
19322 <span class="ep-desc">Export server configuration as JSON</span>
19323 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19324 </div>
19325 <div class="ep-body">
19326 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19327 <p class="curl-heading">Example</p>
19328 <div class="curl-wrap">
19329 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19330 -o config.json \
19331 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19332 <button class="curl-copy-btn" data-target="c-export">Copy</button>
19333 </div>
19334 </div>
19335 </div>
19336
19337 <div class="ep-card">
19338 <div class="ep-header">
19339 <span class="method post">POST</span>
19340 <span class="ep-path">/import-config</span>
19341 <span class="auth-badge protected">Protected</span>
19342 <span class="ep-desc">Import server configuration</span>
19343 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19344 </div>
19345 <div class="ep-body">
19346 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19347 <p class="curl-heading">Example</p>
19348 <div class="curl-wrap">
19349 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19350 -H "Authorization: Bearer $SLOC_API_KEY" \
19351 -H "Content-Type: application/json" \
19352 -d @config.json \
19353 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19354 <button class="curl-copy-btn" data-target="c-import">Copy</button>
19355 </div>
19356 </div>
19357 </div>
19358 </div>
19359
19360 <!-- CI Ingest -->
19361 <div class="section">
19362 <h2 class="section-title">CI Ingest</h2>
19363
19364 <div class="ep-card">
19365 <div class="ep-header">
19366 <span class="method post">POST</span>
19367 <span class="ep-path">/api/ingest</span>
19368 <span class="auth-badge protected">Protected</span>
19369 <span class="ep-desc">Push a pre-computed scan result from CI</span>
19370 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19371 </div>
19372 <div class="ep-body">
19373 <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>
19374 <p class="params-heading">Query Parameters</p>
19375 <table class="params">
19376 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19377 <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>
19378 </table>
19379 <p class="params-heading">Request Body (application/json)</p>
19380 <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>
19381 <details class="schema"><summary>Response schema</summary>
19382<div class="schema-block">// 201 Created
19383{
19384 "run_id": string, // UUID of the ingested run
19385 "view_url": string // relative URL to the report page
19386}</div></details>
19387 <p class="curl-heading">Example</p>
19388 <div class="curl-wrap">
19389 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19390 -H "Authorization: Bearer $SLOC_API_KEY" \
19391 -H "Content-Type: application/json" \
19392 -d @result.json \
19393 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19394 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19395 </div>
19396 </div>
19397 </div>
19398 </div>
19399
19400 <!-- Artifact Download -->
19401 <div class="section">
19402 <h2 class="section-title">Artifact Download</h2>
19403
19404 <div class="ep-card">
19405 <div class="ep-header">
19406 <span class="method get">GET</span>
19407 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19408 <span class="auth-badge protected">Protected</span>
19409 <span class="ep-desc">Download or view a scan artifact</span>
19410 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19411 </div>
19412 <div class="ep-body">
19413 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19414 <p class="params-heading">Path Parameters</p>
19415 <table class="params">
19416 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19417 <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>
19418 <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>
19419 </table>
19420 <p class="params-heading">Query Parameters</p>
19421 <table class="params">
19422 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19423 <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>
19424 </table>
19425 <p class="curl-heading">Example — download JSON result</p>
19426 <div class="curl-wrap">
19427 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19428 -o result.json \
19429 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
19430 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19431 </div>
19432 </div>
19433 </div>
19434 </div>
19435
19436 <!-- Embed Widget -->
19437 <div class="section">
19438 <h2 class="section-title">Embed Widget</h2>
19439
19440 <div class="ep-card">
19441 <div class="ep-header">
19442 <span class="method get">GET</span>
19443 <span class="ep-path">/embed/summary</span>
19444 <span class="auth-badge protected">Protected</span>
19445 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19446 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19447 </div>
19448 <div class="ep-body">
19449 <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>
19450 <p class="params-heading">Query Parameters</p>
19451 <table class="params">
19452 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19453 <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>
19454 <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>
19455 </table>
19456 <p class="curl-heading">Example</p>
19457 <div class="curl-wrap">
19458 <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"
19459 width="460" height="260" style="border:none"></iframe></pre>
19460 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19461 </div>
19462 </div>
19463 </div>
19464 </div>
19465
19466 <!-- Confluence Integration -->
19467 <div class="section">
19468 <h2 class="section-title">Confluence Integration</h2>
19469
19470 <div class="ep-card">
19471 <div class="ep-header">
19472 <span class="method get">GET</span>
19473 <span class="ep-path">/api/confluence/config</span>
19474 <span class="auth-badge protected">Protected</span>
19475 <span class="ep-desc">Get current Confluence configuration</span>
19476 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19477 </div>
19478 <div class="ep-body">
19479 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19480 <details class="schema"><summary>Response schema</summary>
19481<div class="schema-block">{
19482 "configured": boolean,
19483 "tier": "cloud" | "server",
19484 "base_url": string,
19485 "username": string,
19486 "api_token_set": boolean,
19487 "space_key": string,
19488 "parent_page_id": string | null,
19489 "schedule_auto_post": { "<schedule_id>": boolean }
19490}</div></details>
19491 <p class="curl-heading">Example</p>
19492 <div class="curl-wrap">
19493 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19494 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19495 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19496 </div>
19497 </div>
19498 </div>
19499
19500 <div class="ep-card">
19501 <div class="ep-header">
19502 <span class="method post">POST</span>
19503 <span class="ep-path">/api/confluence/config</span>
19504 <span class="auth-badge protected">Protected</span>
19505 <span class="ep-desc">Save Confluence configuration</span>
19506 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19507 </div>
19508 <div class="ep-body">
19509 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19510 <p class="params-heading">Request Body (application/json)</p>
19511 <table class="params">
19512 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19513 <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>
19514 <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>
19515 <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>
19516 <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>
19517 <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>
19518 <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>
19519 <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>
19520 </table>
19521 <details class="schema"><summary>Response schema</summary>
19522<div class="schema-block">{ "ok": true }</div></details>
19523 <p class="curl-heading">Example</p>
19524 <div class="curl-wrap">
19525 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19526 -H "Authorization: Bearer $SLOC_API_KEY" \
19527 -H "Content-Type: application/json" \
19528 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19529 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19530 <button class="curl-copy-btn" data-target="c-cf-save">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/test</span>
19539 <span class="auth-badge protected">Protected</span>
19540 <span class="ep-desc">Test Confluence connection</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">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19545 <details class="schema"><summary>Response schema</summary>
19546<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19547 <p class="curl-heading">Example</p>
19548 <div class="curl-wrap">
19549 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19550 -H "Authorization: Bearer $SLOC_API_KEY" \
19551 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19552 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19553 </div>
19554 </div>
19555 </div>
19556
19557 <div class="ep-card">
19558 <div class="ep-header">
19559 <span class="method post">POST</span>
19560 <span class="ep-path">/api/confluence/post</span>
19561 <span class="auth-badge protected">Protected</span>
19562 <span class="ep-desc">Publish a scan report to Confluence</span>
19563 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19564 </div>
19565 <div class="ep-body">
19566 <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>
19567 <p class="params-heading">Request Body (application/json)</p>
19568 <table class="params">
19569 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19570 <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>
19571 <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>
19572 <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>
19573 </table>
19574 <details class="schema"><summary>Response schema</summary>
19575<div class="schema-block">// 200 OK
19576{ "ok": true, "page_id": string }
19577
19578// 400 / 502 on error
19579{ "ok": false, "error": string }</div></details>
19580 <p class="curl-heading">Example</p>
19581 <div class="curl-wrap">
19582 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19583 -H "Authorization: Bearer $SLOC_API_KEY" \
19584 -H "Content-Type: application/json" \
19585 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
19586 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19587 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19588 </div>
19589 </div>
19590 </div>
19591
19592 <div class="ep-card">
19593 <div class="ep-header">
19594 <span class="method get">GET</span>
19595 <span class="ep-path">/api/confluence/wiki-markup</span>
19596 <span class="auth-badge protected">Protected</span>
19597 <span class="ep-desc">Get Confluence wiki markup for a run</span>
19598 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19599 </div>
19600 <div class="ep-body">
19601 <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>
19602 <p class="params-heading">Query Parameters</p>
19603 <table class="params">
19604 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19605 <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>
19606 </table>
19607 <p class="curl-heading">Example</p>
19608 <div class="curl-wrap">
19609 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19610 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
19611 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19612 </div>
19613 </div>
19614 </div>
19615 </div>
19616
19617 <!-- Authentication -->
19618 <div class="section">
19619 <h2 class="section-title">Authentication</h2>
19620 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19621
19622 <div class="ep-card">
19623 <div class="ep-header">
19624 <span class="method get">GET</span>
19625 <span class="ep-path">/auth/login</span>
19626 <span class="auth-badge public">Public</span>
19627 <span class="ep-desc">Login page</span>
19628 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19629 </div>
19630 <div class="ep-body">
19631 <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>
19632 <p class="params-heading">Query Parameters</p>
19633 <table class="params">
19634 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19635 <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>
19636 <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>
19637 </table>
19638 </div>
19639 </div>
19640
19641 <div class="ep-card">
19642 <div class="ep-header">
19643 <span class="method post">POST</span>
19644 <span class="ep-path">/auth/login</span>
19645 <span class="auth-badge public">Public</span>
19646 <span class="ep-desc">Submit credentials and get a session cookie</span>
19647 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19648 </div>
19649 <div class="ep-body">
19650 <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>
19651 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19652 <table class="params">
19653 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19654 <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>
19655 <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>
19656 </table>
19657 <p class="curl-heading">Example</p>
19658 <div class="curl-wrap">
19659 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19660 -d "key=$SLOC_API_KEY&next=/" \
19661 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19662 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19663 </div>
19664 </div>
19665 </div>
19666 </div>
19667
19668 <!-- Coverage Suggestion -->
19669 <div class="section">
19670 <h2 class="section-title">Coverage Suggestion</h2>
19671
19672 <div class="ep-card">
19673 <div class="ep-header">
19674 <span class="method get">GET</span>
19675 <span class="ep-path">/api/suggest-coverage</span>
19676 <span class="auth-badge protected">Protected</span>
19677 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19678 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19679 </div>
19680 <div class="ep-body">
19681 <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>
19682 <p class="params-heading">Query Parameters</p>
19683 <table class="params">
19684 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19685 <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>
19686 </table>
19687 <details class="schema"><summary>Response schema</summary>
19688<div class="schema-block">{
19689 "found": string | null, // absolute path to the coverage file, if detected
19690 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19691 "hint": string | null // shell command to generate coverage if not found
19692}</div></details>
19693 <p class="curl-heading">Example</p>
19694 <div class="curl-wrap">
19695 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19696 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19697 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19698 </div>
19699 </div>
19700 </div>
19701 </div>
19702
19703 </div>
19704
19705 <footer class="site-footer">
19706 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
19707 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19708 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19709 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19710 · <a href="/api-docs" rel="noopener">REST API</a>
19711 </footer>
19712
19713 <script nonce="{{ csp_nonce }}">
19714 (function () {
19715 var base = window.location.origin;
19716 document.getElementById('base-url').textContent = base;
19717 document.querySelectorAll('.base-url-slot').forEach(function (el) {
19718 el.textContent = base;
19719 });
19720
19721 document.querySelectorAll('.ep-header').forEach(function (hdr) {
19722 hdr.addEventListener('click', function () {
19723 hdr.closest('.ep-card').classList.toggle('open');
19724 });
19725 });
19726
19727 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19728 btn.addEventListener('click', function () {
19729 var targetId = btn.dataset.target;
19730 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19731 if (!pre) return;
19732 navigator.clipboard.writeText(pre.textContent).then(function () {
19733 btn.textContent = 'Copied!';
19734 btn.classList.add('copied');
19735 setTimeout(function () {
19736 btn.textContent = 'Copy';
19737 btn.classList.remove('copied');
19738 }, 2000);
19739 });
19740 });
19741 });
19742
19743 var storageKey = 'oxide-sloc-theme';
19744 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19745 var themeBtn = document.getElementById('theme-toggle');
19746 if (themeBtn) {
19747 themeBtn.addEventListener('click', function () {
19748 var dark = document.body.classList.toggle('dark-theme');
19749 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19750 });
19751 }
19752 (function() {
19753 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'}];
19754 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);});}
19755 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19756 var btn=document.getElementById('settings-btn');if(!btn)return;
19757 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19758 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>';
19759 document.body.appendChild(m);
19760 var g=document.getElementById('scheme-grid');
19761 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);});
19762 var cl=document.getElementById('settings-close');
19763 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);
19764 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');});
19765 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19766 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19767 })();
19768 (function randomizeWatermarks() {
19769 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19770 if (!wms.length) return;
19771 var placed = [];
19772 function tooClose(top, left) {
19773 for (var i = 0; i < placed.length; i++) {
19774 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19775 if (dt < 16 && dl < 12) return true;
19776 }
19777 return false;
19778 }
19779 function pick(leftBand) {
19780 for (var attempt = 0; attempt < 50; attempt++) {
19781 var top = Math.random() * 88 + 2;
19782 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19783 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19784 }
19785 var top = Math.random() * 88 + 2;
19786 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19787 placed.push([top, left]); return [top, left];
19788 }
19789 var half = Math.floor(wms.length / 2);
19790 wms.forEach(function (img, i) {
19791 var pos = pick(i < half);
19792 var size = Math.floor(Math.random() * 100 + 120);
19793 var rot = (Math.random() * 360).toFixed(1);
19794 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19795 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;
19796 });
19797 })();
19798 (function spawnCodeParticles() {
19799 var container = document.getElementById('code-particles');
19800 if (!container) return;
19801 var snippets = [
19802 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19803 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19804 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19805 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19806 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19807 ];
19808 var count = 38;
19809 for (var i = 0; i < count; i++) {
19810 (function(idx) {
19811 var el = document.createElement('span');
19812 el.className = 'code-particle';
19813 el.textContent = snippets[idx % snippets.length];
19814 var left = Math.random() * 94 + 2;
19815 var top = Math.random() * 88 + 6;
19816 var dur = (Math.random() * 10 + 9).toFixed(1);
19817 var delay = (Math.random() * 18).toFixed(1);
19818 var rot = (Math.random() * 26 - 13).toFixed(1);
19819 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19820 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
19821 container.appendChild(el);
19822 })(i);
19823 }
19824 })();
19825 }());
19826 </script>
19827</body>
19828</html>
19829"##,
19830 ext = "html"
19831)]
19832struct ApiDocsTemplate {
19833 has_api_key: bool,
19834 csp_nonce: String,
19835 version: &'static str,
19836}