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 csv_path: Option<PathBuf>,
414 xlsx_path: Option<PathBuf>,
415 scan_config_path: Option<PathBuf>,
416 report_title: String,
417 result_context: RunResultContext,
418}
419
420#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
422 let protected = Router::new()
424 .route("/", get(splash))
425 .route("/scan-setup", get(scan_setup_handler))
426 .route("/scan", get(index))
427 .route("/analyze", post(analyze_handler))
428 .route("/preview", get(preview_handler))
429 .route("/api/suggest-coverage", get(api_suggest_coverage))
430 .route("/pick-directory", get(pick_directory_handler))
431 .route("/open-path", get(open_path_handler))
432 .route("/pick-file", get(pick_file_handler))
433 .route("/locate-report", post(locate_report_handler))
434 .route("/locate-reports-dir", post(locate_reports_dir_handler))
435 .route("/relocate-scan", post(relocate_scan_handler))
436 .route("/watched-dirs/add", post(add_watched_dir_handler))
437 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
438 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
439 .route("/view-reports", get(history_handler))
440 .route("/compare-scans", get(compare_select_handler))
441 .route("/compare", get(compare_handler))
442 .route("/images/{folder}/{file}", get(image_handler))
443 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
444 .route("/api/metrics/latest", get(api_metrics_latest_handler))
445 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
446 .route("/api/metrics/history", get(api_metrics_history_handler))
447 .route(
448 "/api/metrics/submodules",
449 get(api_metrics_submodules_handler),
450 )
451 .route("/api/ingest", post(api_ingest_handler))
452 .route("/api/project-history", get(project_history_handler))
453 .route("/trend-reports", get(trend_report_handler))
454 .route("/test-metrics", get(test_metrics_handler))
455 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
456 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
457 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
458 .route("/runs/result/{run_id}", get(async_run_result_handler))
459 .route("/embed/summary", get(embed_handler))
460 .route("/git-browser", get(git_browser::git_browser_handler))
462 .route("/api/git/refs", get(git_browser::api_list_refs))
463 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
464 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
465 .route("/export-config", get(export_config_handler))
467 .route("/import-config", post(import_config_handler))
468 .route("/api/scan-profiles", get(api_list_scan_profiles))
470 .route("/api/scan-profiles", post(api_save_scan_profile))
471 .route(
472 "/api/scan-profiles/{id}",
473 axum::routing::delete(api_delete_scan_profile),
474 )
475 .route("/integrations", get(integrations::integrations_handler))
477 .route(
478 "/webhook-setup",
479 get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
480 )
481 .route(
482 "/confluence-setup",
483 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
484 )
485 .route("/api/schedules", get(git_webhook::api_list_schedules))
486 .route("/api/schedules", post(git_webhook::api_create_schedule))
487 .route(
488 "/api/schedules",
489 axum::routing::delete(git_webhook::api_delete_schedule),
490 )
491 .route(
492 "/api/confluence/config",
493 get(confluence::api_get_confluence_config),
494 )
495 .route(
496 "/api/confluence/config",
497 post(confluence::api_save_confluence_config),
498 )
499 .route(
500 "/api/confluence/test",
501 post(confluence::api_test_confluence),
502 )
503 .route(
504 "/api/confluence/post",
505 post(confluence::api_post_to_confluence),
506 )
507 .route(
508 "/api/confluence/wiki-markup",
509 get(confluence::api_wiki_markup),
510 )
511 .route("/api-docs", get(api_docs_handler))
513 .route_layer(middleware::from_fn_with_state(
514 state.clone(),
515 require_api_key,
516 ));
517
518 protected
519 .route("/healthz", get(healthz))
520 .route("/badge/{metric}", get(badge_handler))
521 .route("/static/chart.js", get(chart_js_handler))
522 .route("/auth/login", get(auth_login_get))
523 .route("/auth/login", post(auth_login_post))
524 .route("/webhooks/github", post(git_webhook::handle_github_webhook))
526 .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
527 .route(
528 "/webhooks/bitbucket",
529 post(git_webhook::handle_bitbucket_webhook),
530 )
531 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
532 .layer(middleware::from_fn_with_state(
533 state.clone(),
534 add_security_headers,
535 ))
536 .layer(build_cors_layer(state.server_mode))
537 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
538 .with_state(state)
539}
540
541pub fn make_test_router() -> Router {
543 let tmp = std::env::temp_dir().join("sloc_test");
544 let state = AppState {
545 base_config: AppConfig::default(),
546 artifacts: Arc::new(Mutex::new(HashMap::new())),
547 async_runs: Arc::new(Mutex::new(HashMap::new())),
548 registry: Arc::new(Mutex::new(ScanRegistry::default())),
549 registry_path: tmp.join("registry.json"),
550 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
551 server_mode: false,
552 tls_enabled: false,
553 api_keys: vec![],
554 rate_limiter: Arc::new(IpRateLimiter::new(
555 Duration::from_mins(1),
556 600,
557 10,
558 Duration::from_hours(1),
559 )),
560 trust_proxy: false,
561 git_clones_dir: tmp.join("git-clones"),
562 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
563 schedules_path: tmp.join("schedules.json"),
564 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
565 scan_profiles_path: tmp.join("scan_profiles.json"),
566 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
567 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
568 confluence_path: tmp.join("confluence_config.json"),
569 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
570 watched_dirs_path: tmp.join("watched_dirs.json"),
571 };
572 build_router(state)
573}
574
575#[allow(clippy::too_many_lines)]
586pub async fn serve(config: AppConfig) -> Result<()> {
587 let bind_address = config.web.bind_address.clone();
589 let server_mode = config.web.server_mode;
590 let output_root = resolve_output_root(None);
591 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
593 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
594 let mut registry = ScanRegistry::load(®istry_path);
595 registry.prune_stale();
596 let _ = registry.save(®istry_path);
597
598 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
599 .or_else(|_| std::env::var("SLOC_API_KEY"))
600 .unwrap_or_default()
601 .split(',')
602 .map(str::trim)
603 .filter(|s| !s.is_empty())
604 .map(|s| secrecy::Secret::new(s.to_owned()))
605 .collect();
606 if server_mode && api_keys.is_empty() {
607 println!(
608 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
609 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
610 );
611 }
612
613 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
614 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
615 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
616 if server_mode && !tls_enabled {
617 println!(
618 "WARNING: TLS is not configured. Traffic is cleartext. \
619 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
620 or terminate TLS at a reverse proxy (nginx, caddy)."
621 );
622 }
623 if server_mode {
624 println!(
625 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
626 to restrict cross-origin access (comma-separated)."
627 );
628 }
629 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
630 if trust_proxy {
631 println!(
632 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
633 Only set this when oxide-sloc is behind a trusted reverse proxy."
634 );
635 }
636
637 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
638 .ok()
639 .and_then(|v| v.parse::<u32>().ok())
640 .unwrap_or(10);
641 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
642 .ok()
643 .and_then(|v| v.parse::<u64>().ok())
644 .unwrap_or(3600);
645 let rate_limiter = Arc::new(IpRateLimiter::new(
647 Duration::from_mins(1),
648 600,
649 auth_lockout_threshold,
650 Duration::from_secs(auth_lockout_secs),
651 ));
652 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
653
654 let git_clones_dir = resolve_git_clones_dir(&output_root);
655 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
656 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
657 let schedules = ScheduleStore::load(&schedules_path);
658 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
659 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
660 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
661 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
662 |_| output_root.join("confluence_config.json"),
663 PathBuf::from,
664 );
665 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
666 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
667 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
668 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
669
670 let state = AppState {
671 base_config: config,
672 artifacts: Arc::new(Mutex::new(HashMap::new())),
673 async_runs: Arc::new(Mutex::new(HashMap::new())),
674 registry: Arc::new(Mutex::new(registry)),
675 registry_path,
676 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
677 server_mode,
678 tls_enabled,
679 api_keys,
680 rate_limiter,
681 trust_proxy,
682 git_clones_dir,
683 schedules: Arc::new(Mutex::new(schedules)),
684 schedules_path,
685 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
686 scan_profiles_path,
687 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
688 confluence: Arc::new(Mutex::new(confluence)),
689 confluence_path,
690 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
691 watched_dirs_path,
692 };
693
694 restart_poll_schedules(&state).await;
695
696 let app = build_router(state.clone());
697
698 let preferred: SocketAddr = bind_address
703 .parse()
704 .with_context(|| format!("invalid bind address: {bind_address}"))?;
705 let (listener, addr) = {
706 let candidates = (0u16..=9).map(|offset| {
707 let mut a = preferred;
708 a.set_port(preferred.port().saturating_add(offset));
709 a
710 });
711 let mut found = None;
712 for candidate in candidates {
713 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
714 found = Some((l, candidate));
715 break;
716 }
717 }
718 found.ok_or_else(|| {
719 anyhow::anyhow!(
720 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
721 bind_address,
722 preferred.port(),
723 preferred.port().saturating_add(9)
724 )
725 })?
726 };
727 if addr != preferred {
728 eprintln!(
729 "NOTE: port {} is blocked by a system socket (Windows zombie); \
730 using {} instead.",
731 preferred.port(),
732 addr.port()
733 );
734 }
735
736 if tls_enabled {
737 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
738 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
739 let tls_config = build_tls_config(&cert_path, &key_path)
740 .context("failed to load TLS certificate/key")?;
741 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
742
743 let url = format!("https://{addr}/");
744 println!("OxideSLOC server running at {url} (TLS)");
745 println!("Use Ctrl+C to stop.");
746
747 return serve_tls(listener, app, acceptor, server_mode).await;
748 }
749
750 let url = format!("http://{addr}/");
751 log_startup_url(&url, server_mode);
752
753 axum::serve(
754 listener,
755 app.into_make_service_with_connect_info::<SocketAddr>(),
756 )
757 .with_graceful_shutdown(shutdown_signal(server_mode))
758 .await
759 .context("web server terminated unexpectedly")
760}
761
762fn primary_lan_ip() -> Option<String> {
766 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
767 socket.connect("8.8.8.8:80").ok()?;
768 let addr = socket.local_addr().ok()?;
769 let ip = addr.ip();
770 if ip.is_loopback() {
771 return None;
772 }
773 Some(ip.to_string())
774}
775
776fn log_startup_url(url: &str, server_mode: bool) {
778 if server_mode {
779 println!("OxideSLOC server running at {url}");
780 println!("Use Ctrl+C to stop.");
781 } else {
782 println!("OxideSLOC local web UI running at {url}");
783 println!("Press Ctrl+C to stop the server.");
784 let open_url = url.to_owned();
785 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
786 }
787}
788
789fn open_browser_tab(url: &str) {
791 #[cfg(target_os = "windows")]
792 let _ = std::process::Command::new("cmd")
793 .args(["/c", "start", "", url])
794 .stdout(Stdio::null())
795 .stderr(Stdio::null())
796 .spawn();
797 #[cfg(target_os = "macos")]
798 let _ = std::process::Command::new("open")
799 .arg(url)
800 .stdout(Stdio::null())
801 .stderr(Stdio::null())
802 .spawn();
803 #[cfg(target_os = "linux")]
804 let _ = std::process::Command::new("xdg-open")
805 .arg(url)
806 .stdout(Stdio::null())
807 .stderr(Stdio::null())
808 .spawn();
809}
810
811async fn shutdown_signal(server_mode: bool) {
813 if tokio::signal::ctrl_c().await.is_ok() {
814 println!();
815 if server_mode {
816 println!("Shutting down OxideSLOC server...");
817 } else {
818 println!("Shutting down OxideSLOC local web UI...");
819 }
820 println!("Server stopped cleanly.");
821 }
822}
823
824fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
826 use rustls_pemfile::{certs, private_key};
827 use std::io::BufReader;
828
829 let cert_bytes =
830 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
831 let key_bytes =
832 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
833
834 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
835 .collect::<std::result::Result<_, _>>()
836 .context("failed to parse TLS certificates")?;
837
838 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
839 .context("failed to parse TLS private key")?
840 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
841
842 rustls::ServerConfig::builder()
843 .with_no_client_auth()
844 .with_single_cert(cert_chain, key)
845 .context("failed to build TLS server config")
846}
847
848async fn serve_tls(
850 listener: tokio::net::TcpListener,
851 app: Router,
852 acceptor: tokio_rustls::TlsAcceptor,
853 server_mode: bool,
854) -> Result<()> {
855 use hyper_util::rt::{TokioExecutor, TokioIo};
856 use hyper_util::server::conn::auto::Builder as ConnBuilder;
857 use hyper_util::service::TowerToHyperService;
858 use tower::{Service, ServiceExt};
859
860 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
861
862 loop {
863 tokio::select! {
864 biased;
865 _ = tokio::signal::ctrl_c() => {
866 println!();
867 if server_mode {
868 println!("Shutting down OxideSLOC server...");
869 } else {
870 println!("Shutting down OxideSLOC local web UI...");
871 }
872 println!("Server stopped cleanly.");
873 return Ok(());
874 }
875 result = listener.accept() => {
876 let (tcp, peer_addr) = result.context("TLS accept failed")?;
877 let acceptor = acceptor.clone();
878 let mut factory = make_svc.clone();
879
880 tokio::spawn(async move {
881 let tls = match acceptor.accept(tcp).await {
882 Ok(s) => s,
883 Err(e) => {
884 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
885 return;
886 }
887 };
888 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
889 Ok(f) => match Service::call(f, peer_addr).await {
890 Ok(s) => s,
891 Err(_) => return,
892 },
893 Err(_) => return,
894 };
895 let io = TokioIo::new(tls);
896 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
897 .serve_connection(io, TowerToHyperService::new(svc))
898 .await
899 {
900 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
901 }
902 });
903 }
904 }
905 }
906}
907
908#[allow(clippy::too_many_lines)] async fn require_api_key(
910 State(state): State<AppState>,
912 req: Request<Body>,
913 next: Next,
914) -> Response {
915 if state.api_keys.is_empty() {
916 return next.run(req).await;
917 }
918
919 let keys = &state.api_keys;
920 let peer_ip = req
921 .extensions()
922 .get::<axum::extract::ConnectInfo<SocketAddr>>()
923 .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
924
925 let auth_header = req
927 .headers()
928 .get(header::AUTHORIZATION)
929 .and_then(|v| v.to_str().ok())
930 .and_then(|v| v.strip_prefix("Bearer "))
931 .map(str::to_owned);
932 let x_api_key = req
933 .headers()
934 .get("X-API-Key")
935 .and_then(|v| v.to_str().ok())
936 .map(str::to_owned);
937 let session_cookie = req
938 .headers()
939 .get(header::COOKIE)
940 .and_then(|v| v.to_str().ok())
941 .and_then(extract_session_cookie)
942 .map(str::to_owned);
943
944 let session_valid = session_cookie.as_deref().is_some_and(|tok| {
945 let now = Instant::now();
946 let mut sessions = state
947 .sessions
948 .lock()
949 .unwrap_or_else(std::sync::PoisonError::into_inner);
950 if let Some(&expiry) = sessions.get(tok) {
951 if now < expiry {
952 return true;
953 }
954 sessions.remove(tok);
955 }
956 false
957 });
958
959 let any_credential_provided =
960 auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
961
962 let valid = session_valid
963 || [&auth_header, &x_api_key]
964 .iter()
965 .filter_map(|o| o.as_deref())
966 .any(|k| {
967 keys.iter().any(|expected| {
968 use secrecy::ExposeSecret;
969 ct_eq(k, expected.expose_secret())
970 })
971 });
972
973 if valid {
974 return next.run(req).await;
975 }
976
977 if state.rate_limiter.is_auth_locked_out(peer_ip) {
978 tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
979 "Authentication locked out after repeated failures");
980 let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
981 let retry_after = HeaderValue::from_str(&remaining.to_string())
982 .unwrap_or(HeaderValue::from_static("3600"));
983 if is_browser_request(&req) {
984 let minutes = remaining.div_ceil(60).max(1);
985 let s = if minutes == 1 { "" } else { "s" };
986 let body = format!(
987 r#"<!doctype html><html><head><meta charset="utf-8">
988<title>Locked Out — OxideSLOC</title>
989<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
990h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
991</head><body>
992<h1>Too many failed sign-in attempts</h1>
993<p>Access from your IP is temporarily locked. Lockout expires in approximately
994<strong>{minutes} minute{s}</strong>.</p>
995<p>To clear immediately, restart the server.</p>
996<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
997threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
998</body></html>"#
999 );
1000 let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
1001 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1002 return resp;
1003 }
1004 let mut resp = (
1005 StatusCode::TOO_MANY_REQUESTS,
1006 format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
1007 )
1008 .into_response();
1009 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1010 return resp;
1011 }
1012
1013 if any_credential_provided {
1014 state.rate_limiter.record_auth_failure(peer_ip);
1016 let path = req.uri().path().to_owned();
1017 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
1018 "API key authentication failed");
1019 return (
1020 StatusCode::UNAUTHORIZED,
1021 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1022 "401 Unauthorized\n",
1023 )
1024 .into_response();
1025 }
1026
1027 if is_browser_request(&req) {
1031 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1032 let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
1033 let location = HeaderValue::from_str(&login_url)
1034 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
1035 let mut resp = StatusCode::FOUND.into_response();
1036 resp.headers_mut().insert(header::LOCATION, location);
1037 return resp;
1038 }
1039
1040 (
1041 StatusCode::UNAUTHORIZED,
1042 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1043 "401 Unauthorized\n",
1044 )
1045 .into_response()
1046}
1047
1048fn ct_eq(a: &str, b: &str) -> bool {
1049 use subtle::ConstantTimeEq;
1050 a.as_bytes().ct_eq(b.as_bytes()).into()
1051}
1052
1053fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
1054 cookie_header.split(';').find_map(|pair| {
1055 let pair = pair.trim();
1056 let (k, v) = pair.split_once('=')?;
1057 if k.trim() == "sloc_session" {
1058 Some(v.trim())
1059 } else {
1060 None
1061 }
1062 })
1063}
1064
1065fn is_browser_request(req: &Request<Body>) -> bool {
1066 req.headers()
1067 .get(header::ACCEPT)
1068 .and_then(|v| v.to_str().ok())
1069 .is_some_and(|a| a.contains("text/html"))
1070}
1071
1072fn urlencode_path(s: &str) -> String {
1073 let mut out = String::with_capacity(s.len());
1074 for b in s.bytes() {
1075 match b {
1076 b'A'..=b'Z'
1077 | b'a'..=b'z'
1078 | b'0'..=b'9'
1079 | b'-'
1080 | b'_'
1081 | b'.'
1082 | b'~'
1083 | b'/'
1084 | b'?'
1085 | b'='
1086 | b'&'
1087 | b'#' => {
1088 out.push(b as char);
1089 }
1090 _ => {
1091 use std::fmt::Write as _;
1092 write!(&mut out, "%{b:02X}").ok();
1093 }
1094 }
1095 }
1096 out
1097}
1098
1099#[derive(serde::Deserialize)]
1102struct LoginQuery {
1103 next: Option<String>,
1104 error: Option<String>,
1105}
1106
1107#[derive(serde::Deserialize)]
1108struct LoginFormData {
1109 key: String,
1110 next: Option<String>,
1111}
1112
1113async fn auth_login_get(
1114 State(state): State<AppState>,
1115 Query(query): Query<LoginQuery>,
1116 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1117) -> Response {
1118 if state.api_keys.is_empty() {
1119 let mut resp = StatusCode::FOUND.into_response();
1120 resp.headers_mut()
1121 .insert(header::LOCATION, HeaderValue::from_static("/"));
1122 return resp;
1123 }
1124 let has_error = query.error.as_deref() == Some("1");
1125 let next_url = query.next.unwrap_or_default();
1126 let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
1127 Html(
1128 LoginTemplate {
1129 csp_nonce,
1130 has_error,
1131 next_url,
1132 lockout_threshold,
1133 }
1134 .render()
1135 .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
1136 )
1137 .into_response()
1138}
1139
1140async fn auth_login_post(
1141 State(state): State<AppState>,
1142 axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
1143 Form(form): Form<LoginFormData>,
1144) -> Response {
1145 let peer_ip = peer_addr.ip();
1146 let next_url = form
1147 .next
1148 .as_deref()
1149 .filter(|s| !s.is_empty())
1150 .unwrap_or("/");
1151 let safe_next = if next_url.starts_with('/') && !next_url.starts_with("//") {
1152 next_url
1153 } else {
1154 "/"
1155 };
1156
1157 let valid = state.api_keys.iter().any(|expected| {
1158 use secrecy::ExposeSecret;
1159 ct_eq(&form.key, expected.expose_secret())
1160 });
1161
1162 if valid {
1163 const SESSION_SECS: u64 = 8 * 3600;
1164 let session_id = uuid::Uuid::new_v4().to_string();
1165 let expiry = Instant::now() + Duration::from_secs(SESSION_SECS);
1166 state
1167 .sessions
1168 .lock()
1169 .unwrap_or_else(std::sync::PoisonError::into_inner)
1170 .insert(session_id.clone(), expiry);
1171 let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
1172 let cookie_value = format!(
1173 "sloc_session={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_SECS}{secure_flag}",
1174 );
1175 let location =
1176 HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
1177 let cookie_hv = HeaderValue::from_str(&cookie_value)
1178 .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
1179 let mut resp = StatusCode::FOUND.into_response();
1180 resp.headers_mut().insert(header::LOCATION, location);
1181 resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
1182 resp
1183 } else {
1184 state.rate_limiter.record_auth_failure(peer_ip);
1185 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
1186 "Login form authentication failed");
1187 let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
1188 let location = HeaderValue::from_str(&error_url)
1189 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
1190 let mut resp = StatusCode::FOUND.into_response();
1191 resp.headers_mut().insert(header::LOCATION, location);
1192 resp
1193 }
1194}
1195
1196fn build_cors_layer(server_mode: bool) -> CorsLayer {
1197 if server_mode {
1198 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1199 .unwrap_or_default()
1200 .split(',')
1201 .filter(|s| !s.is_empty())
1202 .filter_map(|s| s.trim().parse().ok())
1203 .collect();
1204 if allowed.is_empty() {
1205 return CorsLayer::new();
1206 }
1207 CorsLayer::new()
1208 .allow_origin(AllowOrigin::list(allowed))
1209 .allow_methods(AllowMethods::list([
1210 axum::http::Method::GET,
1211 axum::http::Method::POST,
1212 ]))
1213 .allow_headers(AllowHeaders::list([
1214 axum::http::header::AUTHORIZATION,
1215 axum::http::header::CONTENT_TYPE,
1216 ]))
1217 } else {
1218 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1219 let s = origin.to_str().unwrap_or("");
1220 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1221 }))
1222 }
1223}
1224
1225async fn add_security_headers(
1226 State(state): State<AppState>,
1227 mut req: Request<Body>,
1228 next: Next,
1229) -> Response {
1230 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1231 req.extensions_mut().insert(CspNonce(nonce.clone()));
1232 let mut resp = next.run(req).await;
1233 let h = resp.headers_mut();
1234 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1235 h.insert(
1236 "X-Content-Type-Options",
1237 HeaderValue::from_static("nosniff"),
1238 );
1239 h.insert(
1240 "Referrer-Policy",
1241 HeaderValue::from_static("strict-origin-when-cross-origin"),
1242 );
1243 let csp = format!(
1244 "default-src 'self'; \
1245 style-src 'self' 'unsafe-inline'; \
1246 img-src 'self' data: blob:; \
1247 script-src 'self' 'nonce-{nonce}'; \
1248 font-src 'self' data:; \
1249 object-src 'none'; \
1250 frame-ancestors 'none'"
1251 );
1252 h.insert(
1253 "Content-Security-Policy",
1254 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1255 HeaderValue::from_static(
1256 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1257 )
1258 }),
1259 );
1260 h.insert(
1261 "X-Permitted-Cross-Domain-Policies",
1262 HeaderValue::from_static("none"),
1263 );
1264 h.insert(
1265 "Permissions-Policy",
1266 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1267 );
1268 h.insert(
1269 "Cross-Origin-Opener-Policy",
1270 HeaderValue::from_static("same-origin"),
1271 );
1272 h.insert(
1273 "Cross-Origin-Resource-Policy",
1274 HeaderValue::from_static("same-origin"),
1275 );
1276 if state.tls_enabled {
1277 h.insert(
1278 "Strict-Transport-Security",
1279 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1280 );
1281 }
1282 resp
1283}
1284
1285async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1286 let ip = req
1287 .extensions()
1288 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1289 .map(|c| c.0.ip())
1290 .or_else(|| {
1291 if state.trust_proxy {
1292 req.headers()
1293 .get("X-Forwarded-For")
1294 .and_then(|v| v.to_str().ok())
1295 .and_then(|s| s.split(',').next())
1296 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1297 } else {
1298 None
1299 }
1300 })
1301 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1302
1303 if !state.rate_limiter.is_allowed(ip) {
1304 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1305 path = %req.uri().path(), "Rate limit exceeded");
1306 return (
1307 StatusCode::TOO_MANY_REQUESTS,
1308 [(header::RETRY_AFTER, "60")],
1309 "429 Too Many Requests\n",
1310 )
1311 .into_response();
1312 }
1313 next.run(req).await
1314}
1315
1316async fn splash(
1317 State(state): State<AppState>,
1318 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1319) -> impl IntoResponse {
1320 let lan_ip = if state.server_mode {
1321 primary_lan_ip()
1322 } else {
1323 None
1324 };
1325 let port = state
1326 .base_config
1327 .web
1328 .bind_address
1329 .rsplit(':')
1330 .next()
1331 .and_then(|p| p.parse::<u16>().ok())
1332 .unwrap_or(4317);
1333 let template = SplashTemplate {
1334 csp_nonce,
1335 server_mode: state.server_mode,
1336 lan_ip,
1337 port,
1338 version: env!("CARGO_PKG_VERSION"),
1339 };
1340 Html(
1341 template
1342 .render()
1343 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1344 )
1345}
1346
1347async fn index(
1348 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1349 Query(query): Query<IndexQuery>,
1350) -> impl IntoResponse {
1351 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1352 let policy = query
1353 .mixed_line_policy
1354 .unwrap_or_else(|| "code_only".to_string());
1355 let behavior = query
1356 .binary_file_behavior
1357 .unwrap_or_else(|| "skip".to_string());
1358 let cfg = ScanConfig {
1359 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1360 path: query.path.unwrap_or_default(),
1361 include_globs: query.include_globs.unwrap_or_default(),
1362 exclude_globs: query.exclude_globs.unwrap_or_default(),
1363 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1364 mixed_line_policy: policy,
1365 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1366 != Some("off"),
1367 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1368 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1369 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1370 != Some("disabled"),
1371 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1372 binary_file_behavior: behavior,
1373 output_dir: query.output_dir.unwrap_or_default(),
1374 report_title: query.report_title.unwrap_or_default(),
1375 generate_html: query.generate_html.as_deref() != Some("off"),
1376 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1377 };
1378 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1379 } else {
1380 "{}".to_string()
1381 };
1382
1383 let git_repo = query.git_repo.unwrap_or_default();
1384 let git_ref = query.git_ref.unwrap_or_default();
1385
1386 let git_label = make_git_label(&git_repo, &git_ref);
1387 let git_output_dir = if git_label.is_empty() {
1388 String::new()
1389 } else {
1390 desktop_dir().join(&git_label).display().to_string()
1391 };
1392 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1393 let git_output_dir_json =
1394 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1395
1396 let template = IndexTemplate {
1397 version: env!("CARGO_PKG_VERSION"),
1398 prefill_json,
1399 csp_nonce,
1400 git_repo,
1401 git_ref,
1402 git_label_json,
1403 git_output_dir_json,
1404 };
1405
1406 Html(
1407 template
1408 .render()
1409 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1410 )
1411}
1412
1413async fn scan_setup_handler(
1414 State(state): State<AppState>,
1415 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1416) -> impl IntoResponse {
1417 let recent_scans_json = {
1418 let arr: Vec<serde_json::Value> = {
1419 let reg = state.registry.lock().await;
1420 reg.entries
1421 .iter()
1422 .rev()
1423 .take(6)
1424 .map(|e| {
1425 let run_dir = e
1426 .html_path
1427 .as_ref()
1428 .or(e.json_path.as_ref())
1429 .and_then(|p| p.parent().map(PathBuf::from));
1430 let config_val: Option<serde_json::Value> = run_dir
1431 .and_then(|d| find_scan_config_in_dir(&d))
1432 .and_then(|p| fs::read_to_string(&p).ok())
1433 .and_then(|s| serde_json::from_str(&s).ok());
1434 serde_json::json!({
1435 "project_label": e.project_label,
1436 "timestamp": fmt_la_time(e.timestamp_utc),
1437 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1438 "config": config_val,
1439 })
1440 })
1441 .collect()
1442 };
1443 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1444 };
1445
1446 let template = ScanSetupTemplate {
1447 version: env!("CARGO_PKG_VERSION"),
1448 recent_scans_json,
1449 csp_nonce,
1450 };
1451 Html(
1452 template
1453 .render()
1454 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1455 )
1456}
1457
1458async fn healthz() -> &'static str {
1459 "ok"
1460}
1461
1462async fn api_docs_handler(
1463 State(state): State<AppState>,
1464 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1465) -> impl IntoResponse {
1466 let has_api_key = !state.api_keys.is_empty();
1467 Html(
1468 ApiDocsTemplate {
1469 has_api_key,
1470 csp_nonce,
1471 version: env!("CARGO_PKG_VERSION"),
1472 }
1473 .render()
1474 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1475 )
1476}
1477
1478async fn chart_js_handler() -> impl IntoResponse {
1479 (
1480 [(
1481 header::CONTENT_TYPE,
1482 "application/javascript; charset=utf-8",
1483 )],
1484 CHART_JS,
1485 )
1486}
1487
1488#[derive(Debug, Deserialize)]
1489struct AnalyzeForm {
1490 path: String,
1491 git_repo: Option<String>,
1492 git_ref: Option<String>,
1493 mixed_line_policy: Option<MixedLinePolicy>,
1494 python_docstrings_as_comments: Option<String>,
1495 generated_file_detection: Option<String>,
1496 minified_file_detection: Option<String>,
1497 vendor_directory_detection: Option<String>,
1498 include_lockfiles: Option<String>,
1499 binary_file_behavior: Option<BinaryFileBehavior>,
1500 output_dir: Option<String>,
1501 report_title: Option<String>,
1502 report_header_footer: Option<String>,
1503 generate_html: Option<String>,
1504 generate_pdf: Option<String>,
1505 include_globs: Option<String>,
1506 exclude_globs: Option<String>,
1507 submodule_breakdown: Option<String>,
1508 coverage_file: Option<String>,
1509}
1510
1511#[allow(clippy::struct_excessive_bools)]
1512#[derive(Debug, Serialize, Deserialize, Clone)]
1513struct ScanConfig {
1514 oxide_sloc_version: String,
1515 path: String,
1516 include_globs: String,
1517 exclude_globs: String,
1518 submodule_breakdown: bool,
1519 mixed_line_policy: String,
1520 python_docstrings_as_comments: bool,
1521 generated_file_detection: bool,
1522 minified_file_detection: bool,
1523 vendor_directory_detection: bool,
1524 include_lockfiles: bool,
1525 binary_file_behavior: String,
1526 output_dir: String,
1527 report_title: String,
1528 generate_html: bool,
1529 generate_pdf: bool,
1530}
1531
1532#[derive(Debug, Deserialize, Default)]
1533struct IndexQuery {
1534 path: Option<String>,
1535 include_globs: Option<String>,
1536 exclude_globs: Option<String>,
1537 submodule_breakdown: Option<String>,
1538 mixed_line_policy: Option<String>,
1539 python_docstrings_as_comments: Option<String>,
1540 generated_file_detection: Option<String>,
1541 minified_file_detection: Option<String>,
1542 vendor_directory_detection: Option<String>,
1543 include_lockfiles: Option<String>,
1544 binary_file_behavior: Option<String>,
1545 output_dir: Option<String>,
1546 report_title: Option<String>,
1547 generate_html: Option<String>,
1548 generate_pdf: Option<String>,
1549 prefilled: Option<String>,
1550 git_repo: Option<String>,
1551 git_ref: Option<String>,
1552}
1553
1554#[derive(Debug, Deserialize)]
1555struct PreviewQuery {
1556 path: Option<String>,
1557 include_globs: Option<String>,
1558 exclude_globs: Option<String>,
1559}
1560
1561#[cfg(feature = "native-dialog")]
1562#[derive(Debug, Deserialize)]
1563struct PickDirectoryQuery {
1564 kind: Option<String>,
1565 current: Option<String>,
1566}
1567
1568#[cfg(not(feature = "native-dialog"))]
1569#[derive(Debug, Deserialize)]
1570struct PickDirectoryQuery {}
1571
1572#[derive(Debug, Deserialize, Default)]
1573struct ArtifactQuery {
1574 download: Option<String>,
1575}
1576
1577#[cfg(feature = "native-dialog")]
1578#[derive(Debug, Serialize)]
1579struct PickDirectoryResponse {
1580 selected_path: Option<String>,
1581 cancelled: bool,
1582}
1583
1584#[cfg(feature = "native-dialog")]
1585async fn pick_directory_handler(
1586 State(state): State<AppState>,
1587 Query(query): Query<PickDirectoryQuery>,
1588) -> Response {
1589 if state.server_mode {
1590 return StatusCode::NOT_FOUND.into_response();
1591 }
1592
1593 let is_coverage = query.kind.as_deref() == Some("coverage");
1594 let title = match query.kind.as_deref() {
1595 Some("output") => "Select output directory",
1596 Some("reports") => "Select folder containing saved reports",
1597 Some("coverage") => "Select LCOV coverage file",
1598 _ => "Select project directory",
1599 }
1600 .to_owned();
1601 let current = query.current.clone();
1602
1603 let picked = tokio::task::spawn_blocking(move || {
1604 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1607 let fg_tid = win_dialog_focus::attach_to_foreground();
1608 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1609 win_dialog_focus::flash_dialog_when_ready(title.clone());
1610
1611 let mut dialog = rfd::FileDialog::new().set_title(&title);
1612 if let Some(current) = current.as_deref() {
1613 let resolved = resolve_input_path(current);
1614 let seed = if resolved.is_dir() {
1615 Some(resolved)
1616 } else {
1617 resolved.parent().map(Path::to_path_buf)
1618 };
1619 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1620 dialog = dialog.set_directory(seed_dir);
1621 }
1622 }
1623 let result = if is_coverage {
1624 dialog
1625 .add_filter(
1626 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1627 &["info", "lcov", "xml"],
1628 )
1629 .pick_file()
1630 } else {
1631 dialog.pick_folder()
1632 };
1633
1634 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1635 win_dialog_focus::detach_from_foreground(fg_tid);
1636
1637 result
1638 })
1639 .await
1640 .unwrap_or(None);
1641
1642 Json(PickDirectoryResponse {
1643 selected_path: picked.as_ref().map(|p| display_path(p)),
1644 cancelled: picked.is_none(),
1645 })
1646 .into_response()
1647}
1648
1649#[cfg(not(feature = "native-dialog"))]
1650async fn pick_directory_handler(
1651 State(_state): State<AppState>,
1652 Query(_query): Query<PickDirectoryQuery>,
1653) -> Response {
1654 StatusCode::NOT_FOUND.into_response()
1655}
1656
1657#[cfg(feature = "native-dialog")]
1658async fn pick_file_handler(State(state): State<AppState>) -> Response {
1659 if state.server_mode {
1660 return StatusCode::NOT_FOUND.into_response();
1661 }
1662 let picked = tokio::task::spawn_blocking(|| {
1663 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1664 let fg_tid = win_dialog_focus::attach_to_foreground();
1665 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1666 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1667
1668 let result = rfd::FileDialog::new()
1669 .set_title("Select HTML report")
1670 .add_filter("HTML report", &["html"])
1671 .pick_file();
1672
1673 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1674 win_dialog_focus::detach_from_foreground(fg_tid);
1675
1676 result
1677 })
1678 .await
1679 .unwrap_or(None);
1680 Json(PickDirectoryResponse {
1681 selected_path: picked.as_ref().map(|p| display_path(p)),
1682 cancelled: picked.is_none(),
1683 })
1684 .into_response()
1685}
1686
1687#[cfg(not(feature = "native-dialog"))]
1688async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1689 StatusCode::NOT_FOUND.into_response()
1690}
1691
1692#[derive(Deserialize)]
1693struct LocateReportForm {
1694 file_path: String,
1695}
1696
1697fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1699 let html = ErrorTemplate {
1700 message: message.into(),
1701 last_report_url: Some("/view-reports".to_string()),
1702 last_report_label: Some("View Reports".to_string()),
1703 csp_nonce: csp_nonce.to_owned(),
1704 }
1705 .render()
1706 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1707 Html(html).into_response()
1708}
1709
1710fn registry_entry_from_run(
1712 run: &AnalysisRun,
1713 json_path: PathBuf,
1714 html_path: PathBuf,
1715) -> RegistryEntry {
1716 let project_label = run.input_roots.first().map_or_else(
1717 || "Unknown Project".to_string(),
1718 |r| sanitize_project_label(r),
1719 );
1720 RegistryEntry {
1721 run_id: run.tool.run_id.clone(),
1722 timestamp_utc: run.tool.timestamp_utc,
1723 project_label,
1724 input_roots: run.input_roots.clone(),
1725 json_path: Some(json_path),
1726 html_path: Some(html_path),
1727 pdf_path: None,
1728 summary: ScanSummarySnapshot {
1729 files_analyzed: run.summary_totals.files_analyzed,
1730 files_skipped: run.summary_totals.files_skipped,
1731 total_physical_lines: run.summary_totals.total_physical_lines,
1732 code_lines: run.summary_totals.code_lines,
1733 comment_lines: run.summary_totals.comment_lines,
1734 blank_lines: run.summary_totals.blank_lines,
1735 functions: run.summary_totals.functions,
1736 classes: run.summary_totals.classes,
1737 variables: run.summary_totals.variables,
1738 imports: run.summary_totals.imports,
1739 test_count: run.summary_totals.test_count,
1740 },
1741 csv_path: None,
1742 xlsx_path: None,
1743 git_branch: None,
1744 git_commit: None,
1745 git_author: None,
1746 git_tags: None,
1747 git_nearest_tag: None,
1748 git_commit_date: None,
1749 }
1750}
1751
1752pub(crate) async fn register_artifacts_in_registry(
1755 state: &AppState,
1756 label: &str,
1757 run: &AnalysisRun,
1758 artifacts: &RunArtifacts,
1759) {
1760 let Some(json_path) = artifacts.json_path.clone() else {
1761 return;
1762 };
1763 let Some(html_path) = artifacts.html_path.clone() else {
1764 return;
1765 };
1766 let mut entry = registry_entry_from_run(run, json_path, html_path);
1767 entry.project_label = label.to_owned();
1768 let mut reg = state.registry.lock().await;
1769 reg.add_entry(entry);
1770 let _ = reg.save(&state.registry_path);
1771}
1772
1773#[allow(clippy::result_large_err)]
1778fn validate_locate_request(
1779 state: &AppState,
1780 file_path: &str,
1781 csp_nonce: &str,
1782) -> Result<(PathBuf, PathBuf), Response> {
1783 let file_ext = Path::new(file_path)
1784 .extension()
1785 .and_then(|e| e.to_str())
1786 .unwrap_or("")
1787 .to_ascii_lowercase();
1788 if file_ext != "html" {
1789 return Err(locate_report_error(
1790 "Only .html report files can be located via this form.",
1791 csp_nonce,
1792 ));
1793 }
1794 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1795 Ok(p) => strip_unc_prefix(p),
1796 Err(_) => {
1797 return Err(locate_report_error(
1798 "Report file not found or path is invalid.",
1799 csp_nonce,
1800 ));
1801 }
1802 };
1803 if state.server_mode {
1804 let output_root = resolve_output_root(None);
1805 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1806 if !html_path.starts_with(&canonical_root) {
1807 return Err(locate_report_error(
1808 "Report file must be within the configured output directory.",
1809 csp_nonce,
1810 ));
1811 }
1812 }
1813 let parent = match html_path.parent() {
1814 Some(p) => p.to_path_buf(),
1815 None => {
1816 return Err(locate_report_error(
1817 "Report file has no parent directory.",
1818 csp_nonce,
1819 ));
1820 }
1821 };
1822 Ok((html_path, parent))
1823}
1824
1825fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1827 if server_mode {
1828 String::new()
1829 } else {
1830 format!("\n\nFile: {}", path.display())
1831 }
1832}
1833
1834async fn locate_report_handler(
1835 State(state): State<AppState>,
1836 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1837 Form(form): Form<LocateReportForm>,
1838) -> impl IntoResponse {
1839 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1840 Ok(v) => v,
1841 Err(resp) => return resp,
1842 };
1843
1844 let json_candidate = parent.join("result.json");
1845 let mut reg = state.registry.lock().await;
1846 let entry_idx = reg.entries.iter().position(|e| {
1848 let json_match = e
1849 .json_path
1850 .as_ref()
1851 .and_then(|p| p.parent())
1852 .is_some_and(|p| p == parent);
1853 let html_match = e
1854 .html_path
1855 .as_ref()
1856 .and_then(|p| p.parent())
1857 .is_some_and(|p| p == parent);
1858 json_match || html_match
1859 });
1860 if let Some(idx) = entry_idx {
1861 reg.entries[idx].html_path = Some(html_path);
1862 let _ = reg.save(&state.registry_path);
1863 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1864 }
1865 if json_candidate.exists() {
1867 match read_json(&json_candidate) {
1868 Ok(run) => {
1869 let entry = registry_entry_from_run(&run, json_candidate, html_path);
1870 reg.add_entry(entry);
1871 let _ = reg.save(&state.registry_path);
1872 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1873 }
1874 Err(e) => {
1875 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1876 let err_detail = if state.server_mode {
1877 String::new()
1878 } else {
1879 format!("\n\nError: {e}")
1880 };
1881 return locate_report_error(
1882 format!(
1883 "Could not link this report.\n\nA 'result.json' was found but could not \
1884 be parsed — it may have been saved by an older version of OxideSLOC. \
1885 Re-running the analysis will create a fresh, compatible \
1886 record.{file_hint}{err_detail}"
1887 ),
1888 &csp_nonce,
1889 );
1890 }
1891 }
1892 }
1893 drop(reg);
1894 let file_hint = locate_path_hint(state.server_mode, &html_path);
1895 locate_report_error(
1896 format!(
1897 "Could not link this report.\n\nNo matching scan record was found, and no \
1898 'result.json' was found in the same folder.{file_hint}"
1899 ),
1900 &csp_nonce,
1901 )
1902}
1903
1904fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1906 fs::read_dir(dir)
1907 .ok()?
1908 .flatten()
1909 .map(|e| e.path())
1910 .find(|p| {
1911 p.is_file()
1912 && p.file_stem()
1913 .and_then(|n| n.to_str())
1914 .is_some_and(|n| n.starts_with("result"))
1915 && p.extension()
1916 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1917 })
1918}
1919
1920#[derive(Deserialize)]
1921struct LocateReportsDirForm {
1922 folder_path: String,
1923}
1924
1925#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
1927 State(state): State<AppState>,
1929 Form(form): Form<LocateReportsDirForm>,
1930) -> impl IntoResponse {
1931 if state.server_mode {
1932 return StatusCode::NOT_FOUND.into_response();
1933 }
1934 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1935 Ok(p) => strip_unc_prefix(p),
1936 Err(_) => {
1937 return axum::response::Redirect::to(
1938 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1939 )
1940 .into_response();
1941 }
1942 };
1943 if !folder.is_dir() {
1944 return axum::response::Redirect::to(
1945 "/view-reports?error=Selected+path+is+not+a+directory.",
1946 )
1947 .into_response();
1948 }
1949
1950 let mut candidates: Vec<PathBuf> = Vec::new();
1953 if let Some(j) = find_result_json_in_dir(&folder) {
1954 candidates.push(j);
1955 }
1956 if let Ok(dir_entries) = fs::read_dir(&folder) {
1957 for entry in dir_entries.flatten() {
1958 let sub = entry.path();
1959 if sub.is_dir() {
1960 if let Some(j) = find_result_json_in_dir(&sub) {
1961 candidates.push(j);
1962 }
1963 }
1964 }
1965 }
1966
1967 if candidates.is_empty() {
1968 return axum::response::Redirect::to(
1969 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1970 )
1971 .into_response();
1972 }
1973
1974 let mut linked_count: usize = 0;
1975 let mut reg = state.registry.lock().await;
1976 for json_path in candidates {
1977 let parent = match json_path.parent() {
1978 Some(p) => p.to_path_buf(),
1979 None => continue,
1980 };
1981 let already = reg.entries.iter().any(|e| {
1984 let dir_match = e
1985 .json_path
1986 .as_ref()
1987 .and_then(|p| p.parent())
1988 .is_some_and(|p| p == parent)
1989 || e.html_path
1990 .as_ref()
1991 .and_then(|p| p.parent())
1992 .is_some_and(|p| p == parent);
1993 dir_match
1994 && (e.json_path.as_ref().is_some_and(|p| p.exists())
1995 || e.html_path.as_ref().is_some_and(|p| p.exists()))
1996 });
1997 if already {
1998 continue;
1999 }
2000 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2002 rd.flatten()
2003 .map(|e| e.path())
2004 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2005 });
2006 let Ok(run) = read_json(&json_path) else {
2007 continue;
2008 };
2009 let project_label = run.input_roots.first().map_or_else(
2010 || "Unknown Project".to_string(),
2011 |r| sanitize_project_label(r),
2012 );
2013 let entry = RegistryEntry {
2014 run_id: run.tool.run_id.clone(),
2015 timestamp_utc: run.tool.timestamp_utc,
2016 project_label,
2017 input_roots: run.input_roots.clone(),
2018 json_path: Some(json_path),
2019 html_path,
2020 pdf_path: None,
2021 csv_path: None,
2022 xlsx_path: None,
2023 summary: ScanSummarySnapshot {
2024 files_analyzed: run.summary_totals.files_analyzed,
2025 files_skipped: run.summary_totals.files_skipped,
2026 total_physical_lines: run.summary_totals.total_physical_lines,
2027 code_lines: run.summary_totals.code_lines,
2028 comment_lines: run.summary_totals.comment_lines,
2029 blank_lines: run.summary_totals.blank_lines,
2030 functions: run.summary_totals.functions,
2031 classes: run.summary_totals.classes,
2032 variables: run.summary_totals.variables,
2033 imports: run.summary_totals.imports,
2034 test_count: run.summary_totals.test_count,
2035 },
2036 git_branch: run.git_branch.clone(),
2037 git_commit: run.git_commit_short.clone(),
2038 git_author: run.git_commit_author.clone(),
2039 git_tags: run.git_tags.clone(),
2040 git_nearest_tag: run.git_nearest_tag.clone(),
2041 git_commit_date: run.git_commit_date.clone(),
2042 };
2043 reg.add_entry(entry);
2044 linked_count += 1;
2045 }
2046 let _ = reg.save(&state.registry_path);
2047 drop(reg);
2048
2049 if linked_count == 0 {
2050 return axum::response::Redirect::to(
2051 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2052 )
2053 .into_response();
2054 }
2055 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2056}
2057
2058#[derive(Deserialize)]
2059struct RelocateScanForm {
2060 run_id: String,
2061 folder_path: String,
2062 redirect_url: String,
2063}
2064
2065#[allow(clippy::too_many_lines)] async fn relocate_scan_handler(
2067 State(state): State<AppState>,
2069 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2070 Form(form): Form<RelocateScanForm>,
2071) -> impl IntoResponse {
2072 if state.server_mode {
2073 return StatusCode::NOT_FOUND.into_response();
2074 }
2075
2076 let run_id = form.run_id.trim().to_string();
2077 let redirect_url = form.redirect_url.trim().to_string();
2078
2079 let run_exists = {
2080 let reg = state.registry.lock().await;
2081 reg.find_by_run_id(&run_id).is_some()
2082 };
2083 if !run_exists {
2084 let html = ErrorTemplate {
2085 message: format!("Run ID '{run_id}' not found in registry."),
2086 last_report_url: Some("/compare-scans".to_string()),
2087 last_report_label: Some("Compare Scans".to_string()),
2088 csp_nonce: csp_nonce.clone(),
2089 }
2090 .render()
2091 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2092 return Html(html).into_response();
2093 }
2094
2095 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2096 Ok(p) => strip_unc_prefix(p),
2097 Err(_) => {
2098 return missing_scan_relocate_response(
2099 "Folder not found or path is invalid.",
2100 &run_id,
2101 form.folder_path.trim(),
2102 &redirect_url,
2103 false,
2104 &csp_nonce,
2105 );
2106 }
2107 };
2108
2109 if !folder.is_dir() {
2110 return missing_scan_relocate_response(
2111 "Selected path is not a directory.",
2112 &run_id,
2113 &folder.display().to_string(),
2114 &redirect_url,
2115 false,
2116 &csp_nonce,
2117 );
2118 }
2119
2120 let json_candidates: Vec<PathBuf> = fs::read_dir(&folder)
2121 .ok()
2122 .into_iter()
2123 .flatten()
2124 .flatten()
2125 .map(|e| e.path())
2126 .filter(|p| {
2127 p.is_file()
2128 && p.file_stem()
2129 .and_then(|n| n.to_str())
2130 .is_some_and(|n| n.starts_with("result"))
2131 && p.extension()
2132 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2133 })
2134 .collect();
2135
2136 if json_candidates.is_empty() {
2137 return missing_scan_relocate_response(
2138 &format!(
2139 "No result JSON files found in the selected folder.\nSearched: {}",
2140 folder.display()
2141 ),
2142 &run_id,
2143 &folder.display().to_string(),
2144 &redirect_url,
2145 false,
2146 &csp_nonce,
2147 );
2148 }
2149
2150 let mut matched_json: Option<PathBuf> = None;
2151 for candidate in &json_candidates {
2152 if let Ok(run) = read_json(candidate) {
2153 if run.tool.run_id == run_id {
2154 matched_json = Some(candidate.clone());
2155 break;
2156 }
2157 }
2158 }
2159
2160 let Some(json_path) = matched_json else {
2161 return missing_scan_relocate_response(
2162 &format!(
2163 "No matching scan found in the selected folder.\n\
2164 The JSON files present do not contain run ID: {run_id}\n\
2165 Searched: {}",
2166 folder.display()
2167 ),
2168 &run_id,
2169 &folder.display().to_string(),
2170 &redirect_url,
2171 false,
2172 &csp_nonce,
2173 );
2174 };
2175
2176 let html_path = fs::read_dir(&folder)
2177 .ok()
2178 .into_iter()
2179 .flatten()
2180 .flatten()
2181 .map(|e| e.path())
2182 .find(|p| {
2183 p.is_file()
2184 && p.file_stem()
2185 .and_then(|n| n.to_str())
2186 .is_some_and(|n| n.starts_with("result"))
2187 && p.extension()
2188 .is_some_and(|e| e.eq_ignore_ascii_case("html"))
2189 });
2190 let pdf_path = fs::read_dir(&folder)
2191 .ok()
2192 .into_iter()
2193 .flatten()
2194 .flatten()
2195 .map(|e| e.path())
2196 .find(|p| {
2197 p.is_file()
2198 && p.file_stem()
2199 .and_then(|n| n.to_str())
2200 .is_some_and(|n| n.starts_with("result"))
2201 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
2202 });
2203
2204 {
2205 let mut reg = state.registry.lock().await;
2206 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2207 entry.json_path = Some(json_path);
2208 if let Some(hp) = html_path {
2209 entry.html_path = Some(hp);
2210 }
2211 if let Some(pp) = pdf_path {
2212 entry.pdf_path = Some(pp);
2213 }
2214 }
2215 let _ = reg.save(&state.registry_path);
2216 }
2217
2218 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2219 redirect_url
2220 } else {
2221 "/compare-scans".to_string()
2222 };
2223 axum::response::Redirect::to(&safe_redirect).into_response()
2224}
2225
2226fn missing_scan_relocate_response(
2227 message: &str,
2228 run_id: &str,
2229 folder_hint: &str,
2230 redirect_url: &str,
2231 server_mode: bool,
2232 csp_nonce: &str,
2233) -> axum::response::Response {
2234 let html = RelocateScanTemplate {
2235 message: message.to_string(),
2236 run_id: run_id.to_string(),
2237 folder_hint: folder_hint.to_string(),
2238 redirect_url: redirect_url.to_string(),
2239 server_mode,
2240 csp_nonce: csp_nonce.to_owned(),
2241 }
2242 .render()
2243 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2244 (StatusCode::NOT_FOUND, Html(html)).into_response()
2245}
2246
2247fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2252 let mut candidates: Vec<PathBuf> = Vec::new();
2253 if let Some(j) = find_result_json_in_dir(folder) {
2254 candidates.push(j);
2255 }
2256 if let Ok(dir_entries) = fs::read_dir(folder) {
2257 for entry in dir_entries.flatten() {
2258 let sub = entry.path();
2259 if sub.is_dir() {
2260 if let Some(j) = find_result_json_in_dir(&sub) {
2261 candidates.push(j);
2262 }
2263 }
2264 }
2265 }
2266
2267 let mut linked = 0usize;
2268 for json_path in candidates {
2269 let parent = match json_path.parent() {
2270 Some(p) => p.to_path_buf(),
2271 None => continue,
2272 };
2273 let already = reg.entries.iter().any(|e| {
2274 let dir_match = e
2275 .json_path
2276 .as_ref()
2277 .and_then(|p| p.parent())
2278 .is_some_and(|p| p == parent)
2279 || e.html_path
2280 .as_ref()
2281 .and_then(|p| p.parent())
2282 .is_some_and(|p| p == parent);
2283 dir_match
2284 && (e.json_path.as_ref().is_some_and(|p| p.exists())
2285 || e.html_path.as_ref().is_some_and(|p| p.exists()))
2286 });
2287 if already {
2288 continue;
2289 }
2290 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2291 rd.flatten()
2292 .map(|e| e.path())
2293 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2294 });
2295 let Ok(run) = read_json(&json_path) else {
2296 continue;
2297 };
2298 let project_label = run.input_roots.first().map_or_else(
2299 || "Unknown Project".to_string(),
2300 |r| sanitize_project_label(r),
2301 );
2302 let entry = RegistryEntry {
2303 run_id: run.tool.run_id.clone(),
2304 timestamp_utc: run.tool.timestamp_utc,
2305 project_label,
2306 input_roots: run.input_roots.clone(),
2307 json_path: Some(json_path),
2308 html_path,
2309 pdf_path: None,
2310 csv_path: None,
2311 xlsx_path: None,
2312 summary: ScanSummarySnapshot {
2313 files_analyzed: run.summary_totals.files_analyzed,
2314 files_skipped: run.summary_totals.files_skipped,
2315 total_physical_lines: run.summary_totals.total_physical_lines,
2316 code_lines: run.summary_totals.code_lines,
2317 comment_lines: run.summary_totals.comment_lines,
2318 blank_lines: run.summary_totals.blank_lines,
2319 functions: run.summary_totals.functions,
2320 classes: run.summary_totals.classes,
2321 variables: run.summary_totals.variables,
2322 imports: run.summary_totals.imports,
2323 test_count: run.summary_totals.test_count,
2324 },
2325 git_branch: run.git_branch.clone(),
2326 git_commit: run.git_commit_short.clone(),
2327 git_author: run.git_commit_author.clone(),
2328 git_tags: run.git_tags.clone(),
2329 git_nearest_tag: run.git_nearest_tag.clone(),
2330 git_commit_date: run.git_commit_date.clone(),
2331 };
2332 reg.add_entry(entry);
2333 linked += 1;
2334 }
2335 linked
2336}
2337
2338async fn auto_scan_watched_dirs(state: &AppState) {
2340 let dirs: Vec<PathBuf> = {
2341 let wd = state.watched_dirs.lock().await;
2342 wd.dirs.clone()
2343 };
2344 if dirs.is_empty() {
2345 return;
2346 }
2347 let mut reg = state.registry.lock().await;
2348 let mut total = 0usize;
2349 for dir in &dirs {
2350 if dir.is_dir() {
2351 total += scan_folder_into_registry(dir, &mut reg);
2352 }
2353 }
2354 if total > 0 {
2355 let _ = reg.save(&state.registry_path);
2356 }
2357}
2358
2359#[derive(Deserialize)]
2362struct WatchedDirForm {
2363 folder_path: String,
2364 #[serde(default = "default_redirect")]
2365 redirect_to: String,
2366}
2367
2368fn default_redirect() -> String {
2369 "/view-reports".to_string()
2370}
2371
2372#[derive(Deserialize)]
2373struct WatchedDirRefreshForm {
2374 #[serde(default = "default_redirect")]
2375 redirect_to: String,
2376}
2377
2378fn safe_redirect(dest: &str) -> &str {
2382 if dest.starts_with('/') {
2383 dest
2384 } else {
2385 "/"
2386 }
2387}
2388
2389async fn add_watched_dir_handler(
2392 State(state): State<AppState>,
2393 Form(form): Form<WatchedDirForm>,
2394) -> impl IntoResponse {
2395 if state.server_mode {
2396 return StatusCode::NOT_FOUND.into_response();
2397 }
2398 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2399 strip_unc_prefix(p)
2400 } else {
2401 let dest = format!(
2402 "{}?error=Folder+not+found+or+path+is+invalid.",
2403 safe_redirect(&form.redirect_to)
2404 );
2405 return axum::response::Redirect::to(&dest).into_response();
2406 };
2407 if !folder.is_dir() {
2408 let dest = format!(
2409 "{}?error=Selected+path+is+not+a+directory.",
2410 safe_redirect(&form.redirect_to)
2411 );
2412 return axum::response::Redirect::to(&dest).into_response();
2413 }
2414
2415 {
2417 let mut wd = state.watched_dirs.lock().await;
2418 wd.add(folder.clone());
2419 let _ = wd.save(&state.watched_dirs_path);
2420 }
2421
2422 let linked = {
2424 let mut reg = state.registry.lock().await;
2425 let n = scan_folder_into_registry(&folder, &mut reg);
2426 if n > 0 {
2427 let _ = reg.save(&state.registry_path);
2428 }
2429 n
2430 };
2431
2432 let dest = if linked > 0 {
2433 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2434 } else {
2435 format!(
2436 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2437 safe_redirect(&form.redirect_to)
2438 )
2439 };
2440 axum::response::Redirect::to(&dest).into_response()
2441}
2442
2443async fn remove_watched_dir_handler(
2444 State(state): State<AppState>,
2445 Form(form): Form<WatchedDirForm>,
2446) -> impl IntoResponse {
2447 if state.server_mode {
2448 return StatusCode::NOT_FOUND.into_response();
2449 }
2450 let folder = PathBuf::from(&form.folder_path);
2451 {
2452 let mut wd = state.watched_dirs.lock().await;
2453 wd.remove(&folder);
2454 let _ = wd.save(&state.watched_dirs_path);
2455 }
2456 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2457}
2458
2459async fn refresh_watched_dirs_handler(
2460 State(state): State<AppState>,
2461 Form(form): Form<WatchedDirRefreshForm>,
2462) -> impl IntoResponse {
2463 if state.server_mode {
2464 return StatusCode::NOT_FOUND.into_response();
2465 }
2466 let dirs: Vec<PathBuf> = {
2467 let wd = state.watched_dirs.lock().await;
2468 wd.dirs.clone()
2469 };
2470 let mut total = 0usize;
2471 {
2472 let mut reg = state.registry.lock().await;
2473 for dir in &dirs {
2474 if dir.is_dir() {
2475 total += scan_folder_into_registry(dir, &mut reg);
2476 }
2477 }
2478 if total > 0 {
2479 let _ = reg.save(&state.registry_path);
2480 }
2481 }
2482 let dest = if total > 0 {
2483 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2484 } else {
2485 safe_redirect(&form.redirect_to).to_owned()
2486 };
2487 axum::response::Redirect::to(&dest).into_response()
2488}
2489
2490#[derive(Debug, Deserialize)]
2491struct OpenPathQuery {
2492 path: Option<String>,
2493}
2494
2495async fn open_path_handler(
2496 State(state): State<AppState>,
2497 Query(query): Query<OpenPathQuery>,
2498) -> impl IntoResponse {
2499 if state.server_mode {
2500 return StatusCode::NOT_FOUND.into_response();
2501 }
2502 let raw = match query.path.as_deref() {
2503 Some(p) if !p.is_empty() => p,
2504 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2505 };
2506
2507 let target = match fs::canonicalize(raw) {
2511 Ok(canonical) if canonical.is_file() => match canonical.parent() {
2512 Some(p) => p.to_path_buf(),
2513 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2514 },
2515 Ok(canonical) if canonical.is_dir() => canonical,
2516 Ok(_) => {
2517 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2518 }
2519 Err(_) => {
2520 let mut ancestor = std::path::Path::new(raw);
2522 loop {
2523 match ancestor.parent() {
2524 Some(p) => {
2525 ancestor = p;
2526 if ancestor.is_dir() {
2527 break;
2528 }
2529 }
2530 None => {
2531 return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2532 .into_response();
2533 }
2534 }
2535 }
2536 ancestor.to_path_buf()
2537 }
2538 };
2539
2540 #[cfg(target_os = "windows")]
2541 {
2542 let ps_cmd = "Add-Type -TypeDefinition \
2546 'using System;using System.Runtime.InteropServices;\
2547 public class WF{\
2548 [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2549 [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2550 }'; \
2551 $p=$env:SLOC_OPEN_PATH; \
2552 $sh=New-Object -ComObject Shell.Application; \
2553 $sh.Open($p); \
2554 Start-Sleep -Milliseconds 600; \
2555 foreach($w in $sh.Windows()){ \
2556 try{ \
2557 if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2558 [System.IO.Path]::GetFullPath($p)){ \
2559 [WF]::ShowWindow($w.HWND,3); \
2560 [WF]::SetForegroundWindow($w.HWND); \
2561 break \
2562 } \
2563 }catch{} \
2564 }";
2565 let _ = std::process::Command::new("powershell")
2566 .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2567 .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2568 .stdout(Stdio::null())
2569 .stderr(Stdio::null())
2570 .spawn();
2571 }
2572 #[cfg(target_os = "macos")]
2573 let _ = std::process::Command::new("open")
2574 .arg(&target)
2575 .stdout(Stdio::null())
2576 .stderr(Stdio::null())
2577 .spawn();
2578 #[cfg(target_os = "linux")]
2579 let _ = std::process::Command::new("xdg-open")
2580 .arg(&target)
2581 .stdout(Stdio::null())
2582 .stderr(Stdio::null())
2583 .spawn();
2584
2585 (StatusCode::OK, "ok").into_response()
2586}
2587
2588async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2589 let (content_type, bytes): (&'static str, &'static [u8]) =
2590 match (folder.as_str(), file.as_str()) {
2591 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2592 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2593 ("icons", "c.png") => ("image/png", IMG_ICON_C),
2594 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2595 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2596 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2597 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2598 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2599 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2600 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2601 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2602 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2603 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2604 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2605 ("icons", "r.png") => ("image/png", IMG_ICON_R),
2606 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2607 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2608 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2609 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2610 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2611 _ => return StatusCode::NOT_FOUND.into_response(),
2612 };
2613 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2614}
2615
2616async fn preview_handler(
2617 State(state): State<AppState>,
2618 Query(query): Query<PreviewQuery>,
2619) -> impl IntoResponse {
2620 let raw_path = query
2621 .path
2622 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2623 let resolved = resolve_input_path(&raw_path);
2624
2625 if state.server_mode {
2626 let config = &state.base_config;
2627 if config.discovery.allowed_scan_roots.is_empty() {
2628 return Html(
2629 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2630 );
2631 }
2632 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2633 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2634 fs::canonicalize(root)
2635 .ok()
2636 .is_some_and(|r| canonical.starts_with(&r))
2637 });
2638 if !allowed {
2639 return Html(
2640 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2641 );
2642 }
2643 }
2644
2645 let include_patterns = split_patterns(query.include_globs.as_deref());
2646 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2647
2648 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2649 Ok(html) => Html(html),
2650 Err(err) => Html(format!(
2651 r#"<div class="preview-error">Preview failed: {}</div>"#,
2652 escape_html(&err.to_string())
2653 )),
2654 }
2655}
2656
2657#[derive(Debug, Deserialize, Default)]
2658struct SuggestCoverageQuery {
2659 path: Option<String>,
2660}
2661
2662async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2663 const CANDIDATES: &[&str] = &[
2664 "coverage/lcov.info",
2666 "lcov.info",
2667 "target/llvm-cov/lcov.info",
2668 "target/coverage/lcov.info",
2669 "target/debug/coverage/lcov.info",
2670 "coverage/coverage.lcov",
2671 "build/coverage/lcov.info",
2672 "reports/lcov.info",
2673 "coverage.xml",
2675 "coverage/coverage.xml",
2676 "target/site/cobertura/coverage.xml",
2677 "build/reports/coverage/coverage.xml",
2678 "target/site/jacoco/jacoco.xml",
2680 "build/reports/jacoco/test/jacocoTestReport.xml",
2681 "build/reports/jacoco/jacocoTestReport.xml",
2682 "build/jacoco/jacoco.xml",
2683 ];
2684 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2685 let found = CANDIDATES
2686 .iter()
2687 .map(|rel| root.join(rel))
2688 .find(|p| p.is_file())
2689 .map(|p| display_path(&p));
2690
2691 let (tool, hint) = detect_coverage_tool(&root);
2692 Json(serde_json::json!({ "found": found, "tool": tool, "hint": hint }))
2693}
2694
2695fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2698 if root.join("Cargo.toml").is_file() {
2699 return (
2700 Some("cargo-llvm-cov"),
2701 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2702 );
2703 }
2704 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2705 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2706 }
2707 if root.join("pom.xml").is_file() {
2708 return (Some("jacoco"), Some("mvn test jacoco:report"));
2709 }
2710 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2711 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2712 }
2713 (None, None)
2714}
2715
2716#[allow(clippy::result_large_err)]
2718fn validate_server_scan_path(
2719 config: &sloc_config::AppConfig,
2720 resolved_path: &Path,
2721 csp_nonce: &str,
2722) -> Result<(), Response> {
2723 if config.discovery.allowed_scan_roots.is_empty() {
2724 let template = ErrorTemplate {
2725 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2726 Set allowed_scan_roots in the server config to permit scanning."
2727 .to_string(),
2728 last_report_url: None,
2729 last_report_label: None,
2730 csp_nonce: csp_nonce.to_owned(),
2731 };
2732 return Err((
2733 StatusCode::FORBIDDEN,
2734 Html(
2735 template
2736 .render()
2737 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2738 ),
2739 )
2740 .into_response());
2741 }
2742 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2743 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2744 fs::canonicalize(root)
2745 .ok()
2746 .is_some_and(|r| canonical.starts_with(&r))
2747 });
2748 if !allowed {
2749 tracing::warn!(event = "path_rejected", path = %canonical.display(),
2750 "Scan path not in allowed_scan_roots");
2751 let template = ErrorTemplate {
2752 message: "The requested path is not within an allowed scan directory.".to_string(),
2753 last_report_url: None,
2754 last_report_label: None,
2755 csp_nonce: csp_nonce.to_owned(),
2756 };
2757 return Err((
2758 StatusCode::FORBIDDEN,
2759 Html(
2760 template
2761 .render()
2762 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2763 ),
2764 )
2765 .into_response());
2766 }
2767 Ok(())
2768}
2769
2770fn apply_output_dir_exclusions(
2772 config: &mut sloc_config::AppConfig,
2773 project_path: &str,
2774 raw_output_dir: &str,
2775) {
2776 let project_root = resolve_input_path(project_path);
2777 let raw_out = raw_output_dir.trim();
2778 let resolved_out = if raw_out.is_empty() {
2779 project_root.join("sloc")
2780 } else if Path::new(raw_out).is_absolute() {
2781 PathBuf::from(raw_out)
2782 } else {
2783 workspace_root().join(raw_out)
2784 };
2785 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2786 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2787 let dir = first.to_string();
2788 if !config.discovery.excluded_directories.contains(&dir) {
2789 config.discovery.excluded_directories.push(dir);
2790 }
2791 }
2792 }
2793 if !config
2794 .discovery
2795 .excluded_directories
2796 .iter()
2797 .any(|d| d == "sloc")
2798 {
2799 config
2800 .discovery
2801 .excluded_directories
2802 .push("sloc".to_string());
2803 }
2804}
2805
2806const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2808 ScanSummarySnapshot {
2809 files_analyzed: run.summary_totals.files_analyzed,
2810 files_skipped: run.summary_totals.files_skipped,
2811 total_physical_lines: run.summary_totals.total_physical_lines,
2812 code_lines: run.summary_totals.code_lines,
2813 comment_lines: run.summary_totals.comment_lines,
2814 blank_lines: run.summary_totals.blank_lines,
2815 functions: run.summary_totals.functions,
2816 classes: run.summary_totals.classes,
2817 variables: run.summary_totals.variables,
2818 imports: run.summary_totals.imports,
2819 test_count: run.summary_totals.test_count,
2820 }
2821}
2822
2823pub(crate) fn build_run_registry_entry(
2825 run: &AnalysisRun,
2826 run_id: &str,
2827 project_label: &str,
2828 artifacts: &RunArtifacts,
2829) -> RegistryEntry {
2830 RegistryEntry {
2831 run_id: run_id.to_owned(),
2832 timestamp_utc: run.tool.timestamp_utc,
2833 project_label: project_label.to_owned(),
2834 input_roots: run.input_roots.clone(),
2835 json_path: artifacts.json_path.clone(),
2836 html_path: artifacts.html_path.clone(),
2837 pdf_path: artifacts.pdf_path.clone(),
2838 csv_path: artifacts.csv_path.clone(),
2839 xlsx_path: artifacts.xlsx_path.clone(),
2840 summary: summary_snapshot_from_run(run),
2841 git_branch: run.git_branch.clone(),
2842 git_commit: run.git_commit_short.clone(),
2843 git_author: run.git_commit_author.clone(),
2844 git_tags: run.git_tags.clone(),
2845 git_nearest_tag: run.git_nearest_tag.clone(),
2846 git_commit_date: run.git_commit_date.clone(),
2847 }
2848}
2849
2850fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2852 if let Some(policy) = form.mixed_line_policy {
2853 config.analysis.mixed_line_policy = policy;
2854 }
2855 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2856 config.analysis.generated_file_detection =
2857 form.generated_file_detection.as_deref() != Some("disabled");
2858 config.analysis.minified_file_detection =
2859 form.minified_file_detection.as_deref() != Some("disabled");
2860 config.analysis.vendor_directory_detection =
2861 form.vendor_directory_detection.as_deref() != Some("disabled");
2862 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2863 if let Some(binary_behavior) = form.binary_file_behavior {
2864 config.analysis.binary_file_behavior = binary_behavior;
2865 }
2866 if let Some(report_title) = form.report_title.as_deref() {
2867 let trimmed = report_title.trim();
2868 if !trimmed.is_empty() {
2869 config.reporting.report_title = trimmed.to_string();
2870 }
2871 }
2872 if let Some(hf) = form.report_header_footer.as_deref() {
2873 let trimmed = hf.trim();
2874 config.reporting.report_header_footer = if trimmed.is_empty() {
2875 None
2876 } else {
2877 Some(trimmed.to_string())
2878 };
2879 }
2880 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2881 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2882 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2883 if let Some(cov) = &form.coverage_file {
2884 let trimmed = cov.trim();
2885 if !trimmed.is_empty() {
2886 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2887 }
2888 }
2889}
2890
2891fn spawn_pdf_background(
2895 pending_pdf: PendingPdf,
2896 run_id: String,
2897 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
2898) {
2899 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2900 tokio::spawn(async move {
2901 let result = tokio::task::spawn_blocking(move || {
2902 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2903 if cleanup_src {
2904 let _ = fs::remove_file(&pdf_src);
2905 }
2906 r
2907 })
2908 .await;
2909 let failed = match result {
2910 Ok(Ok(())) => false,
2911 Ok(Err(err)) => {
2912 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
2913 true
2914 }
2915 Err(err) => {
2916 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
2917 true
2918 }
2919 };
2920 if failed {
2921 let mut map = artifacts.lock().await;
2922 if let Some(entry) = map.get_mut(&run_id) {
2923 entry.pdf_path = None;
2924 }
2925 }
2926 });
2927 }
2928}
2929
2930fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2932 cmp.file_deltas
2933 .iter()
2934 .map(|f| match f.status {
2935 FileChangeStatus::Added => f.current_code,
2936 FileChangeStatus::Modified => f.code_delta.max(0),
2937 _ => 0,
2938 })
2939 .sum()
2940}
2941
2942fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2944 cmp.file_deltas
2945 .iter()
2946 .map(|f| match f.status {
2947 FileChangeStatus::Removed => f.baseline_code,
2948 FileChangeStatus::Modified => (-f.code_delta).max(0),
2949 _ => 0,
2950 })
2951 .sum()
2952}
2953
2954fn build_submodule_row(
2956 s: &sloc_core::SubmoduleSummary,
2957 run: &AnalysisRun,
2958 run_id: &str,
2959 run_dir: &Path,
2960 generate_html: bool,
2961) -> SubmoduleRow {
2962 let safe = sanitize_project_label(&s.name);
2963 let artifact_key = format!("sub_{safe}");
2964 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2965 let parent_path = run
2966 .input_roots
2967 .first()
2968 .map_or("", std::string::String::as_str);
2969 let sub_run = build_sub_run(run, s, parent_path);
2970 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2971 let path = run_dir.join(format!("{artifact_key}.html"));
2972 if fs::write(&path, sub_html.as_bytes()).is_ok() {
2973 Some(format!("/runs/{artifact_key}/{run_id}"))
2974 } else {
2975 None
2976 }
2977 })
2978 } else {
2979 None
2980 };
2981 SubmoduleRow {
2982 name: s.name.clone(),
2983 relative_path: s.relative_path.clone(),
2984 files_analyzed: s.files_analyzed,
2985 code_lines: s.code_lines,
2986 comment_lines: s.comment_lines,
2987 blank_lines: s.blank_lines,
2988 total_physical_lines: s.total_physical_lines,
2989 html_url,
2990 }
2991}
2992
2993#[allow(clippy::too_many_lines)]
2996#[allow(clippy::similar_names)]
2997async fn analyze_handler(
2998 State(state): State<AppState>,
3000 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3001 Form(form): Form<AnalyzeForm>,
3002) -> impl IntoResponse {
3003 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3004 let template = ErrorTemplate {
3005 message: "Server is busy — too many concurrent analyses. Please try again in a moment."
3006 .to_string(),
3007 last_report_url: None,
3008 last_report_label: None,
3009 csp_nonce: csp_nonce.clone(),
3010 };
3011 return (
3012 StatusCode::SERVICE_UNAVAILABLE,
3013 Html(
3014 template
3015 .render()
3016 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3017 ),
3018 )
3019 .into_response();
3020 };
3021
3022 let mut config = state.base_config.clone();
3023
3024 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3025 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3026 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3027
3028 if !is_git_mode {
3029 let resolved_path = resolve_input_path(&form.path);
3030 if state.server_mode {
3031 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3032 return resp;
3033 }
3034 }
3035 config.discovery.root_paths = vec![resolved_path];
3036 }
3037
3038 apply_form_to_config(&mut config, &form);
3039 apply_output_dir_exclusions(
3040 &mut config,
3041 &form.path,
3042 form.output_dir.as_deref().unwrap_or(""),
3043 );
3044
3045 let wait_id = uuid::Uuid::new_v4().to_string();
3047 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3048
3049 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3051
3052 let project_path_bg = form.path.clone();
3054 let output_dir_bg = form.output_dir.clone();
3055 let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
3056 let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
3057 let generate_html_bg = form.generate_html.is_some();
3058 let generate_pdf_bg = form.generate_pdf.is_some();
3059 let clones_dir = state.git_clones_dir.clone();
3060 let wait_id_bg = wait_id.clone();
3061 let state_bg = state.clone();
3062 let cancel_bg = Arc::clone(&cancel_token);
3063
3064 {
3065 let mut runs = state.async_runs.lock().await;
3066 runs.insert(
3067 wait_id.clone(),
3068 AsyncRunState::Running {
3069 started_at: std::time::Instant::now(),
3070 cancel_token,
3071 },
3072 );
3073 }
3074
3075 tokio::spawn(async move {
3076 let _permit = sem_permit;
3078
3079 let git_repo_sb = git_repo_bg.clone();
3081 let git_ref_sb = git_ref_bg.clone();
3082 let cancel_sb = Arc::clone(&cancel_bg);
3083 let analysis_result =
3084 tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
3085 if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
3086 let dest = git_clone_dest(repo, &clones_dir);
3087 sloc_git::clone_or_fetch(repo, &dest)?;
3088 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3089 sloc_git::create_worktree(&dest, refname, &wt)?;
3090 config.discovery.root_paths = vec![wt.clone()];
3091 let run = analyze(&config, "serve", Some(&cancel_sb));
3092 let _ = sloc_git::destroy_worktree(&dest, &wt);
3093 let mut run = run?;
3094 if run.git_branch.is_none() {
3095 run.git_branch = Some(refname.clone());
3096 }
3097 let html = render_html(&run)?;
3098 return Ok((run, html));
3099 }
3100 let run = analyze(&config, "serve", Some(&cancel_sb))?;
3101 let html = render_html(&run)?;
3102 Ok((run, html))
3103 })
3104 .await
3105 .map_err(|err| anyhow::anyhow!(err.to_string()))
3106 .and_then(|result| result);
3107
3108 if cancel_bg.load(std::sync::atomic::Ordering::Relaxed) {
3110 let mut runs = state_bg.async_runs.lock().await;
3111 if matches!(
3113 runs.get(&wait_id_bg),
3114 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3115 ) {
3116 runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3117 }
3118 drop(runs);
3119 return;
3120 }
3121
3122 let (run, report_html) = match analysis_result {
3123 Ok(v) => v,
3124 Err(err) => {
3125 let message = if err.to_string().contains("analysis cancelled") {
3127 let mut runs = state_bg.async_runs.lock().await;
3128 runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3129 drop(runs);
3130 return;
3131 } else {
3132 "Analysis failed. Check that the path exists and is readable.".to_string()
3133 };
3134 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3135 let mut runs = state_bg.async_runs.lock().await;
3136 runs.insert(wait_id_bg.clone(), AsyncRunState::Failed { message });
3137 drop(runs);
3138 return;
3139 }
3140 };
3141
3142 let run_id = run.tool.run_id.clone();
3143 tracing::info!(event = "scan_complete", run_id = %run_id,
3144 path = %project_path_bg, files = run.summary_totals.files_analyzed,
3145 "Analysis finished");
3146
3147 let prev_entry: Option<RegistryEntry> = {
3148 let reg = state_bg.registry.lock().await;
3149 reg.entries_for_roots(&run.input_roots)
3150 .into_iter()
3151 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3152 .cloned()
3153 };
3154
3155 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3156 prev.json_path
3157 .as_ref()
3158 .and_then(|p| read_json(p).ok())
3159 .map(|prev_run| compute_delta(&prev_run, &run))
3160 });
3161 let prev_scan_count: usize = {
3162 let reg = state_bg.registry.lock().await;
3163 reg.entries_for_roots(&run.input_roots)
3164 .iter()
3165 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3166 .count()
3167 };
3168
3169 let output_root = resolve_output_root(output_dir_bg.as_deref());
3170
3171 let project_label = if let (Some(repo), Some(refname)) = (
3172 git_repo_bg.as_deref().filter(|s| !s.is_empty()),
3173 git_ref_bg.as_deref().filter(|s| !s.is_empty()),
3174 ) {
3175 let repo_name = repo
3176 .trim_end_matches('/')
3177 .trim_end_matches(".git")
3178 .rsplit('/')
3179 .next()
3180 .unwrap_or("repo");
3181 sanitize_project_label(&format!("{repo_name}_{refname}"))
3182 } else {
3183 sanitize_project_label(&project_path_bg)
3184 };
3185 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3186 let file_stem = {
3187 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
3188 if commit.is_empty() {
3189 project_label.clone()
3190 } else {
3191 format!("{project_label}_{commit}")
3192 }
3193 };
3194
3195 let result_context = RunResultContext {
3196 prev_entry: prev_entry.clone(),
3197 prev_scan_count,
3198 project_path: project_path_bg.clone(),
3199 };
3200
3201 let artifact_result = persist_run_artifacts(
3202 &run,
3203 &report_html,
3204 &run_dir,
3205 true,
3206 generate_html_bg,
3207 generate_pdf_bg,
3208 &run.effective_configuration.reporting.report_title,
3209 &file_stem,
3210 result_context,
3211 );
3212
3213 let (artifacts, pending_pdf) = match artifact_result {
3214 Ok(v) => v,
3215 Err(err) => {
3216 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3217 let mut runs = state_bg.async_runs.lock().await;
3218 runs.insert(
3219 wait_id_bg.clone(),
3220 AsyncRunState::Failed {
3221 message: "Failed to save report artifacts. Check available disk space."
3222 .to_string(),
3223 },
3224 );
3225 drop(runs);
3226 return;
3227 }
3228 };
3229
3230 {
3231 let mut map = state_bg.artifacts.lock().await;
3232 map.insert(run_id.clone(), artifacts.clone());
3233 }
3234
3235 {
3236 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3237 let mut reg = state_bg.registry.lock().await;
3238 reg.add_entry(entry);
3239 let _ = reg.save(&state_bg.registry_path);
3240 }
3241
3242 if let Some(ref cfg_path) = artifacts.scan_config_path {
3243 let policy_str =
3244 serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3245 .ok()
3246 .and_then(|v| v.as_str().map(String::from))
3247 .unwrap_or_else(|| "code_only".to_string());
3248 let behavior_str =
3249 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3250 .ok()
3251 .and_then(|v| v.as_str().map(String::from))
3252 .unwrap_or_else(|| "skip".to_string());
3253 let scan_cfg = ScanConfig {
3254 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3255 path: project_path_bg.clone(),
3256 include_globs: run
3257 .effective_configuration
3258 .discovery
3259 .include_globs
3260 .join("\n"),
3261 exclude_globs: run
3262 .effective_configuration
3263 .discovery
3264 .exclude_globs
3265 .join("\n"),
3266 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3267 mixed_line_policy: policy_str,
3268 python_docstrings_as_comments: run
3269 .effective_configuration
3270 .analysis
3271 .python_docstrings_as_comments,
3272 generated_file_detection: run
3273 .effective_configuration
3274 .analysis
3275 .generated_file_detection,
3276 minified_file_detection: run
3277 .effective_configuration
3278 .analysis
3279 .minified_file_detection,
3280 vendor_directory_detection: run
3281 .effective_configuration
3282 .analysis
3283 .vendor_directory_detection,
3284 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3285 binary_file_behavior: behavior_str,
3286 output_dir: output_dir_bg.clone().unwrap_or_default(),
3287 report_title: run.effective_configuration.reporting.report_title.clone(),
3288 generate_html: generate_html_bg,
3289 generate_pdf: generate_pdf_bg,
3290 };
3291 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3292 let _ = std::fs::write(cfg_path, json);
3293 }
3294 }
3295
3296 spawn_pdf_background(pending_pdf, run_id.clone(), state_bg.artifacts.clone());
3297
3298 let mut runs = state_bg.async_runs.lock().await;
3300 runs.insert(
3301 wait_id_bg.clone(),
3302 AsyncRunState::Complete {
3303 run_id: run_id.clone(),
3304 },
3305 );
3306 drop(runs);
3307
3308 let _ = scan_delta;
3310 });
3311
3312 let template = ScanWaitTemplate {
3313 version: env!("CARGO_PKG_VERSION"),
3314 wait_id_json,
3315 project_path: form.path.clone(),
3316 csp_nonce,
3317 };
3318 let html = template
3319 .render()
3320 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3321 let mut response = Html(html).into_response();
3322 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3323 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3324 response.headers_mut().insert(name, val);
3325 }
3326 }
3327 response
3328}
3329
3330#[derive(Serialize)]
3333#[serde(tag = "state", rename_all = "snake_case")]
3334enum AsyncRunStatusResponse {
3335 Running { elapsed_secs: u64 },
3336 Complete { run_id: String },
3337 Failed { message: String },
3338 Cancelled,
3339}
3340
3341async fn async_run_status_handler(
3342 State(state): State<AppState>,
3343 AxumPath(wait_id): AxumPath<String>,
3344) -> Response {
3345 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3347 return StatusCode::BAD_REQUEST.into_response();
3348 }
3349 let run_state = {
3350 let runs = state.async_runs.lock().await;
3351 runs.get(&wait_id).cloned()
3352 };
3353 match run_state {
3354 None => StatusCode::NOT_FOUND.into_response(),
3355 Some(AsyncRunState::Running { started_at, .. }) => {
3356 if started_at.elapsed() > std::time::Duration::from_hours(2) {
3358 let mut runs = state.async_runs.lock().await;
3359 runs.insert(
3360 wait_id,
3361 AsyncRunState::Failed {
3362 message: "Analysis timed out after 2 hours.".to_string(),
3363 },
3364 );
3365 drop(runs);
3366 return Json(AsyncRunStatusResponse::Failed {
3367 message: "Analysis timed out after 2 hours.".to_string(),
3368 })
3369 .into_response();
3370 }
3371 Json(AsyncRunStatusResponse::Running {
3372 elapsed_secs: started_at.elapsed().as_secs(),
3373 })
3374 .into_response()
3375 }
3376 Some(AsyncRunState::Complete { run_id }) => {
3377 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3378 }
3379 Some(AsyncRunState::Failed { message }) => {
3380 Json(AsyncRunStatusResponse::Failed { message }).into_response()
3381 }
3382 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3383 }
3384}
3385
3386async fn cancel_run_handler(
3387 State(state): State<AppState>,
3388 AxumPath(wait_id): AxumPath<String>,
3389) -> Response {
3390 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3391 return StatusCode::BAD_REQUEST.into_response();
3392 }
3393 let mut runs = state.async_runs.lock().await;
3394 let resp = match runs.get(&wait_id) {
3395 Some(AsyncRunState::Running { cancel_token, .. }) => {
3396 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3397 runs.insert(wait_id, AsyncRunState::Cancelled);
3398 StatusCode::OK.into_response()
3399 }
3400 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3401 _ => StatusCode::NOT_FOUND.into_response(),
3402 };
3403 drop(runs);
3404 resp
3405}
3406
3407async fn async_run_result_handler(
3408 State(state): State<AppState>,
3409 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3410 AxumPath(run_id): AxumPath<String>,
3411) -> Response {
3412 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3413 return StatusCode::BAD_REQUEST.into_response();
3414 }
3415
3416 let artifacts = {
3417 let map = state.artifacts.lock().await;
3418 map.get(&run_id).cloned()
3419 };
3420 let artifacts = if let Some(a) = artifacts {
3421 a
3422 } else {
3423 let reg = state.registry.lock().await;
3424 if let Some(entry) = reg.find_by_run_id(&run_id) {
3425 recover_artifacts_from_registry(entry)
3426 } else {
3427 let html = ErrorTemplate {
3428 message: format!(
3429 "Report not found. Run ID {} is not in the scan history.",
3430 &run_id[..run_id.len().min(8)]
3431 ),
3432 last_report_url: Some("/view-reports".to_string()),
3433 last_report_label: Some("View Reports".to_string()),
3434 csp_nonce: csp_nonce.clone(),
3435 }
3436 .render()
3437 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3438 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3439 }
3440 };
3441
3442 let json_path = if let Some(p) = &artifacts.json_path {
3443 p.clone()
3444 } else {
3445 let html = ErrorTemplate {
3446 message: "JSON result was not saved for this run.".to_string(),
3447 last_report_url: Some("/view-reports".to_string()),
3448 last_report_label: Some("View Reports".to_string()),
3449 csp_nonce: csp_nonce.clone(),
3450 }
3451 .render()
3452 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3453 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3454 };
3455
3456 let Ok(run) = read_json(&json_path) else {
3457 let folder_hint = json_path
3458 .parent()
3459 .map(|p| p.display().to_string())
3460 .unwrap_or_default();
3461 let redirect_url = format!("/runs/result/{run_id}");
3462 return missing_scan_relocate_response(
3463 &format!(
3464 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
3465 deleted. Browse to the folder containing your scan output to reconnect it.",
3466 json_path.display()
3467 ),
3468 &run_id,
3469 &folder_hint,
3470 &redirect_url,
3471 state.server_mode,
3472 &csp_nonce,
3473 );
3474 };
3475
3476 let confluence_configured = {
3477 let store = state.confluence.lock().await;
3478 store.is_configured()
3479 };
3480
3481 render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3482}
3483
3484#[allow(clippy::too_many_lines)]
3485#[allow(clippy::similar_names)] fn render_result_page(
3487 run: &AnalysisRun,
3489 artifacts: &RunArtifacts,
3490 run_id: &str,
3491 csp_nonce: &str,
3492 confluence_configured: bool,
3493) -> Response {
3494 let ctx = &artifacts.result_context;
3495 let prev_entry = &ctx.prev_entry;
3496 let prev_scan_count = ctx.prev_scan_count;
3497 let project_path = &ctx.project_path;
3498
3499 let scan_delta = prev_entry.as_ref().and_then(|prev| {
3500 prev.json_path
3501 .as_ref()
3502 .and_then(|p| read_json(p).ok())
3503 .map(|prev_run| compute_delta(&prev_run, run))
3504 });
3505
3506 let files_analyzed = run.per_file_records.len() as u64;
3507 let files_skipped = run.skipped_file_records.len() as u64;
3508 let physical_lines = run
3509 .totals_by_language
3510 .iter()
3511 .map(|r| r.total_physical_lines)
3512 .sum::<u64>();
3513 let code_lines = run
3514 .totals_by_language
3515 .iter()
3516 .map(|r| r.code_lines)
3517 .sum::<u64>();
3518 let comment_lines = run
3519 .totals_by_language
3520 .iter()
3521 .map(|r| r.comment_lines)
3522 .sum::<u64>();
3523 let blank_lines = run
3524 .totals_by_language
3525 .iter()
3526 .map(|r| r.blank_lines)
3527 .sum::<u64>();
3528 let mixed_lines = run
3529 .totals_by_language
3530 .iter()
3531 .map(|r| r.mixed_lines_separate)
3532 .sum::<u64>();
3533 let functions = run
3534 .totals_by_language
3535 .iter()
3536 .map(|r| r.functions)
3537 .sum::<u64>();
3538 let classes = run
3539 .totals_by_language
3540 .iter()
3541 .map(|r| r.classes)
3542 .sum::<u64>();
3543 let variables = run
3544 .totals_by_language
3545 .iter()
3546 .map(|r| r.variables)
3547 .sum::<u64>();
3548 let imports = run
3549 .totals_by_language
3550 .iter()
3551 .map(|r| r.imports)
3552 .sum::<u64>();
3553
3554 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3555 let prev_fa = prev_sum.map(|s| s.files_analyzed);
3556 let prev_fs = prev_sum.map(|s| s.files_skipped);
3557 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3558 let prev_cl = prev_sum.map(|s| s.code_lines);
3559 let prev_cml = prev_sum.map(|s| s.comment_lines);
3560 let prev_bl = prev_sum.map(|s| s.blank_lines);
3561 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3562 let prev_fa_str = fmt_prev(prev_fa);
3563 let prev_fs_str = fmt_prev(prev_fs);
3564 let prev_pl_str = fmt_prev(prev_pl);
3565 let prev_cl_str = fmt_prev(prev_cl);
3566 let prev_cml_str = fmt_prev(prev_cml);
3567 let prev_bl_str = fmt_prev(prev_bl);
3568 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3569 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3570 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3571 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3572 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3573 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3574 let delta_fa_class = delta_fa_class.to_string();
3575 let delta_fs_class = delta_fs_class.to_string();
3576 let delta_pl_class = delta_pl_class.to_string();
3577 let delta_cl_class = delta_cl_class.to_string();
3578 let delta_cml_class = delta_cml_class.to_string();
3579 let delta_bl_class = delta_bl_class.to_string();
3580
3581 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3582 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3583 let (delta_lines_net_str, delta_lines_net_class) =
3584 match (delta_lines_added, delta_lines_removed) {
3585 (Some(a), Some(r)) => {
3586 let net = a - r;
3587 (fmt_delta(net), delta_class(net).to_string())
3588 }
3589 _ => ("—".to_string(), "na".to_string()),
3590 };
3591
3592 let run_dir = artifacts.output_dir.clone();
3593 let git_branch = run.git_branch.clone();
3594 let git_commit = run.git_commit_short.clone();
3595 let git_author = run.git_commit_author.clone();
3596
3597 let template = ResultTemplate {
3598 version: env!("CARGO_PKG_VERSION"),
3599 report_title: run.effective_configuration.reporting.report_title.clone(),
3600 project_path: project_path.clone(),
3601 output_dir: display_path(&artifacts.output_dir),
3602 run_id: run_id.to_owned(),
3603 files_analyzed,
3604 files_skipped,
3605 physical_lines,
3606 code_lines,
3607 comment_lines,
3608 blank_lines,
3609 mixed_lines,
3610 functions,
3611 classes,
3612 variables,
3613 imports,
3614 html_url: artifacts
3615 .html_path
3616 .as_ref()
3617 .map(|_| format!("/runs/html/{run_id}")),
3618 pdf_url: artifacts
3619 .pdf_path
3620 .as_ref()
3621 .map(|_| format!("/runs/pdf/{run_id}")),
3622 json_url: artifacts
3623 .json_path
3624 .as_ref()
3625 .map(|_| format!("/runs/json/{run_id}")),
3626 html_download_url: artifacts
3627 .html_path
3628 .as_ref()
3629 .map(|_| format!("/runs/html/{run_id}?download=1")),
3630 pdf_download_url: artifacts
3631 .pdf_path
3632 .as_ref()
3633 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3634 json_download_url: artifacts
3635 .json_path
3636 .as_ref()
3637 .map(|_| format!("/runs/json/{run_id}?download=1")),
3638 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3639 pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3640 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3641 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3642 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3643 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3644 prev_fa_str,
3645 prev_fs_str,
3646 prev_pl_str,
3647 prev_cl_str,
3648 prev_cml_str,
3649 prev_bl_str,
3650 delta_fa_str,
3651 delta_fa_class,
3652 delta_fs_str,
3653 delta_fs_class,
3654 delta_pl_str,
3655 delta_pl_class,
3656 delta_cl_str,
3657 delta_cl_class,
3658 delta_cml_str,
3659 delta_cml_class,
3660 delta_bl_str,
3661 delta_bl_class,
3662 delta_lines_added,
3663 delta_lines_removed,
3664 delta_lines_net_str,
3665 delta_lines_net_class,
3666 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3667 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3668 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3669 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3670 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3671 d.file_deltas
3672 .iter()
3673 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3674 .map(|f| {
3675 #[allow(clippy::cast_sign_loss)]
3676 let n = f.current_code as u64;
3677 n
3678 })
3679 .sum()
3680 }),
3681 git_branch,
3682 git_commit,
3683 git_author,
3684 current_scan_number: prev_scan_count + 1,
3685 prev_scan_count,
3686 submodule_rows: run
3687 .submodule_summaries
3688 .iter()
3689 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3690 .collect(),
3691 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3692 scan_config_url: format!("/runs/scan-config/{run_id}"),
3693 lang_chart_json: {
3694 let entries: Vec<String> = run
3695 .totals_by_language
3696 .iter()
3697 .take(12)
3698 .map(|l| {
3699 let name = l
3700 .language
3701 .display_name()
3702 .replace('\\', "\\\\")
3703 .replace('"', "\\\"");
3704 format!(
3705 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3706 name,
3707 l.code_lines,
3708 l.comment_lines,
3709 l.blank_lines,
3710 l.functions,
3711 l.classes,
3712 l.variables,
3713 l.imports,
3714 l.files,
3715 )
3716 })
3717 .collect();
3718 format!("[{}]", entries.join(","))
3719 },
3720 scatter_chart_json: {
3721 let entries: Vec<String> = run
3722 .totals_by_language
3723 .iter()
3724 .map(|l| {
3725 let name = l
3726 .language
3727 .display_name()
3728 .replace('\\', "\\\\")
3729 .replace('"', "\\\"");
3730 format!(
3731 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3732 name, l.files, l.code_lines, l.total_physical_lines,
3733 )
3734 })
3735 .collect();
3736 format!("[{}]", entries.join(","))
3737 },
3738 semantic_chart_json: {
3739 let entries: Vec<String> = run
3740 .totals_by_language
3741 .iter()
3742 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3743 .map(|l| {
3744 let name = l
3745 .language
3746 .display_name()
3747 .replace('\\', "\\\\")
3748 .replace('"', "\\\"");
3749 format!(
3750 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3751 name, l.functions, l.classes, l.variables, l.imports,
3752 )
3753 })
3754 .collect();
3755 format!("[{}]", entries.join(","))
3756 },
3757 submodule_chart_json: {
3758 let entries: Vec<String> = run
3759 .submodule_summaries
3760 .iter()
3761 .map(|s| {
3762 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3763 format!(
3764 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3765 name,
3766 s.code_lines,
3767 s.comment_lines,
3768 s.blank_lines,
3769 s.total_physical_lines,
3770 s.files_analyzed,
3771 )
3772 })
3773 .collect();
3774 format!("[{}]", entries.join(","))
3775 },
3776 has_submodule_data: !run.submodule_summaries.is_empty(),
3777 has_semantic_data: run
3778 .totals_by_language
3779 .iter()
3780 .any(|l| l.functions > 0 || l.classes > 0),
3781 csp_nonce: csp_nonce.to_owned(),
3782 confluence_configured,
3783 report_header_footer: run
3784 .effective_configuration
3785 .reporting
3786 .report_header_footer
3787 .clone(),
3788 };
3789
3790 Html(
3791 template
3792 .render()
3793 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3794 )
3795 .into_response()
3796}
3797
3798fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3799 let slug: String = report_title
3800 .chars()
3801 .map(|c| {
3802 if c.is_alphanumeric() || c == '-' {
3803 c.to_ascii_lowercase()
3804 } else {
3805 '_'
3806 }
3807 })
3808 .collect::<String>()
3809 .split('_')
3810 .filter(|s| !s.is_empty())
3811 .collect::<Vec<_>>()
3812 .join("_");
3813
3814 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3815
3816 if slug.is_empty() {
3817 format!("report_{short_id}.pdf")
3818 } else {
3819 format!("{slug}_{short_id}.pdf")
3820 }
3821}
3822
3823async fn pdf_status_handler(
3826 State(state): State<AppState>,
3827 AxumPath(run_id): AxumPath<String>,
3828) -> Response {
3829 let pdf_path = {
3830 let registry = state.artifacts.lock().await;
3831 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3832 };
3833 let pdf_path = if pdf_path.is_some() {
3834 pdf_path
3835 } else {
3836 let reg = state.registry.lock().await;
3837 reg.find_by_run_id(&run_id)
3838 .map(recover_artifacts_from_registry)
3839 .and_then(|a| a.pdf_path)
3840 };
3841 let ready = pdf_path.is_some_and(|p| p.exists());
3842 Json(serde_json::json!({"ready": ready})).into_response()
3843}
3844
3845fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3850 let Some(start) = html.find("nonce=\"") else {
3852 return html
3856 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3857 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3858 };
3859 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
3861 return html.to_owned();
3862 };
3863 let old_nonce = &html[value_start..value_start + end_offset];
3864 html.replace(
3865 &format!("nonce=\"{old_nonce}\""),
3866 &format!("nonce=\"{new_nonce}\""),
3867 )
3868}
3869
3870fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3871 match fs::read_to_string(path) {
3872 Ok(raw) => {
3873 let content = patch_html_nonce(&raw, csp_nonce);
3875 if wants_download {
3876 (
3877 [
3878 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3879 (
3880 header::CONTENT_DISPOSITION,
3881 "attachment; filename=report.html",
3882 ),
3883 ],
3884 content,
3885 )
3886 .into_response()
3887 } else {
3888 Html(content).into_response()
3889 }
3890 }
3891 Err(err) => {
3892 let filename = path.file_name().map_or_else(
3893 || "report.html".to_string(),
3894 |n| n.to_string_lossy().into_owned(),
3895 );
3896 let msg = format!(
3897 "HTML report '{filename}' could not be read.\n\n\
3898 Error: {err}\n\n\
3899 If you moved or renamed the output folder, the stored path is now stale. \
3900 Use 'Open HTML folder' from the results page to browse the output directory."
3901 );
3902 let html = ErrorTemplate {
3903 message: msg,
3904 last_report_url: Some("/view-reports".to_string()),
3905 last_report_label: Some("View Reports".to_string()),
3906 csp_nonce: csp_nonce.to_owned(),
3907 }
3908 .render()
3909 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3910 (StatusCode::NOT_FOUND, Html(html)).into_response()
3911 }
3912 }
3913}
3914
3915fn serve_pdf_artifact(
3917 path: &Path,
3918 report_title: &str,
3919 run_id: &str,
3920 wants_download: bool,
3921 csp_nonce: &str,
3922) -> Response {
3923 match fs::read(path) {
3924 Ok(bytes) => {
3925 let filename = build_pdf_filename(report_title, run_id);
3926 let disposition = if wants_download {
3927 format!("attachment; filename=\"{filename}\"")
3928 } else {
3929 format!("inline; filename=\"{filename}\"")
3930 };
3931 (
3932 [
3933 (header::CONTENT_TYPE, "application/pdf".to_string()),
3934 (header::CONTENT_DISPOSITION, disposition),
3935 ],
3936 bytes,
3937 )
3938 .into_response()
3939 }
3940 Err(err) => {
3941 let filename = path.file_name().map_or_else(
3942 || "report.pdf".to_string(),
3943 |n| n.to_string_lossy().into_owned(),
3944 );
3945 let msg = format!(
3946 "PDF report '{filename}' could not be read.\n\n\
3947 Error: {err}\n\n\
3948 If you moved or renamed the output folder, the stored path is now stale. \
3949 Use 'Open PDF folder' from the results page to browse the output directory."
3950 );
3951 let html = ErrorTemplate {
3952 message: msg,
3953 last_report_url: Some("/view-reports".to_string()),
3954 last_report_label: Some("View Reports".to_string()),
3955 csp_nonce: csp_nonce.to_owned(),
3956 }
3957 .render()
3958 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3959 (StatusCode::NOT_FOUND, Html(html)).into_response()
3960 }
3961 }
3962}
3963
3964fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3966 match fs::read(path) {
3967 Ok(bytes) => {
3968 if wants_download {
3969 (
3970 [
3971 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3972 (
3973 header::CONTENT_DISPOSITION,
3974 "attachment; filename=result.json",
3975 ),
3976 ],
3977 bytes,
3978 )
3979 .into_response()
3980 } else {
3981 (
3982 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3983 bytes,
3984 )
3985 .into_response()
3986 }
3987 }
3988 Err(err) => {
3989 let filename = path.file_name().map_or_else(
3990 || "result.json".to_string(),
3991 |n| n.to_string_lossy().into_owned(),
3992 );
3993 let msg = format!(
3994 "JSON result '{filename}' could not be read.\n\n\
3995 Error: {err}\n\n\
3996 If you moved or renamed the output folder, the stored path is now stale. \
3997 Use 'Open JSON folder' from the results page to browse the output directory."
3998 );
3999 let html = ErrorTemplate {
4000 message: msg,
4001 last_report_url: Some("/view-reports".to_string()),
4002 last_report_label: Some("View Reports".to_string()),
4003 csp_nonce: csp_nonce.to_owned(),
4004 }
4005 .render()
4006 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4007 (StatusCode::NOT_FOUND, Html(html)).into_response()
4008 }
4009 }
4010}
4011
4012fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4014 let output_dir = entry
4015 .html_path
4016 .as_ref()
4017 .or(entry.json_path.as_ref())
4018 .or(entry.pdf_path.as_ref())
4019 .or(entry.csv_path.as_ref())
4020 .or(entry.xlsx_path.as_ref())
4021 .and_then(|p| p.parent().map(PathBuf::from))
4022 .unwrap_or_default();
4023 let pdf_path = entry.pdf_path.clone().or_else(|| {
4026 let candidate = output_dir.join("report.pdf");
4027 candidate.exists().then_some(candidate)
4028 });
4029 let csv_path = entry.csv_path.clone().or_else(|| {
4033 fs::read_dir(&output_dir).ok().and_then(|entries| {
4034 entries
4035 .filter_map(std::result::Result::ok)
4036 .find(|e| {
4037 let n = e.file_name();
4038 let n = n.to_string_lossy();
4039 n.starts_with("report_") && n.ends_with(".csv")
4040 })
4041 .map(|e| e.path())
4042 })
4043 });
4044 let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4045 fs::read_dir(&output_dir).ok().and_then(|entries| {
4046 entries
4047 .filter_map(std::result::Result::ok)
4048 .find(|e| {
4049 let n = e.file_name();
4050 let n = n.to_string_lossy();
4051 n.starts_with("report_") && n.ends_with(".xlsx")
4052 })
4053 .map(|e| e.path())
4054 })
4055 });
4056 RunArtifacts {
4057 output_dir: output_dir.clone(),
4058 html_path: entry.html_path.clone(),
4059 pdf_path,
4060 json_path: entry.json_path.clone(),
4061 csv_path,
4062 xlsx_path,
4063 scan_config_path: find_scan_config_in_dir(&output_dir),
4064 report_title: entry.project_label.clone(),
4065 result_context: RunResultContext::default(),
4066 }
4067}
4068
4069#[allow(clippy::too_many_lines)]
4070async fn artifact_handler(
4071 State(state): State<AppState>,
4073 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4074 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4075 Query(query): Query<ArtifactQuery>,
4076) -> Response {
4077 let artifact_set = {
4078 let registry = state.artifacts.lock().await;
4079 registry.get(&run_id).cloned()
4080 };
4081
4082 let artifact_set = if let Some(a) = artifact_set {
4085 a
4086 } else {
4087 let reg = state.registry.lock().await;
4088 if let Some(entry) = reg.find_by_run_id(&run_id) {
4089 recover_artifacts_from_registry(entry)
4090 } else {
4091 let short_id = &run_id[..run_id.len().min(8)];
4092 let hint = if matches!(
4093 run_id.as_str(),
4094 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4095 ) {
4096 format!(
4097 " The URL format appears to be reversed — \
4098 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4099 Use the View Reports page to navigate to your scan."
4100 )
4101 } else {
4102 " The report may have been deleted or the report directory moved. \
4103 Use View Reports to browse your scan history."
4104 .to_string()
4105 };
4106 let error_html = ErrorTemplate {
4107 message: format!(
4108 "Report not found. \"{short_id}\" is not a recognized run ID.{hint}"
4109 ),
4110 last_report_url: Some("/view-reports".to_string()),
4111 last_report_label: Some("View Reports".to_string()),
4112 csp_nonce: csp_nonce.clone(),
4113 }
4114 .render()
4115 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4116 return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
4117 }
4118 };
4119
4120 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4121
4122 match artifact.as_str() {
4123 "html" => {
4124 let Some(path) = artifact_set.html_path else {
4125 return StatusCode::NOT_FOUND.into_response();
4126 };
4127 serve_html_artifact(&path, wants_download, &csp_nonce)
4128 }
4129 "pdf" => {
4130 let Some(path) = artifact_set.pdf_path else {
4131 let msg = "PDF report was not generated for this run, or was not recorded in \
4132 the scan registry. Re-run the analysis with PDF output enabled."
4133 .to_string();
4134 let html = ErrorTemplate {
4135 message: msg,
4136 last_report_url: Some(format!("/runs/html/{run_id}")),
4137 last_report_label: Some("View HTML Report".to_string()),
4138 csp_nonce: csp_nonce.clone(),
4139 }
4140 .render()
4141 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4142 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4143 };
4144 if !path.exists() {
4147 let html = format!(
4148 "<!doctype html><html lang=\"en\"><head>\
4149 <meta charset=utf-8>\
4150 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4151 <meta http-equiv=\"refresh\" content=\"5\">\
4152 <title>OxideSLOC | Generating PDF\u{2026}</title>\
4153 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4154 <style nonce=\"{csp_nonce}\">\
4155 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4156 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4157 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4158 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4159 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4160 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4161 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4162 background:var(--bg);color:var(--text);}}\
4163 .top-nav{{position:sticky;top:0;z-index:30;\
4164 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4165 border-bottom:1px solid rgba(255,255,255,0.12);\
4166 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4167 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4168 min-height:56px;display:flex;align-items:center;gap:14px;}}\
4169 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4170 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4171 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4172 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4173 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4174 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4175 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4176 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4177 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4178 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4179 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4180 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4181 justify-content:center;min-height:38px;border-radius:999px;\
4182 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4183 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4184 .theme-toggle .icon-sun{{display:none;}}\
4185 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4186 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4187 .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
4188 display:flex;align-items:center;justify-content:center;\
4189 min-height:calc(100vh - 56px);}}\
4190 .panel{{background:var(--surface);border:1px solid var(--line);\
4191 border-radius:var(--radius);box-shadow:var(--shadow);\
4192 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4193 .spin-ring{{width:56px;height:56px;border-radius:50%;\
4194 border:5px solid var(--line);border-top-color:var(--oxide-2);\
4195 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4196 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4197 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4198 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4199 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4200 min-height:42px;padding:0 20px;border-radius:14px;\
4201 border:1px solid var(--line-strong);text-decoration:none;\
4202 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4203 .back-link:hover{{background:var(--line);}}\
4204 </style></head>\
4205 <body>\
4206 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4207 <a class=\"brand\" href=\"/\">\
4208 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4209 <div class=\"brand-copy\">\
4210 <div class=\"brand-title\">OxideSLOC</div>\
4211 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4212 </div>\
4213 </a>\
4214 <div class=\"nav-right\">\
4215 <a class=\"nav-pill\" href=\"/\">Home</a>\
4216 <div class=\"nav-dropdown\">\
4217 <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>\
4218 <div class=\"nav-dropdown-menu\">\
4219 <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>\
4220 </div>\
4221 </div>\
4222 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
4223 <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>\
4224 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
4225 <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>\
4226 </button>\
4227 </div>\
4228 </div></div>\
4229 <div class=\"page\"><div class=\"panel\">\
4230 <div class=\"spin-ring\"></div>\
4231 <h1>Generating PDF\u{2026}</h1>\
4232 <p>The PDF is being rendered from the HTML report.<br>\
4233 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
4234 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
4235 </div></div>\
4236 <script nonce=\"{csp_nonce}\">\
4237 (function(){{\
4238 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
4239 if(s===\"dark\")b.classList.add(\"dark-theme\");\
4240 var t=document.getElementById(\"theme-toggle\");\
4241 if(t)t.addEventListener(\"click\",function(){{\
4242 var d=b.classList.toggle(\"dark-theme\");\
4243 localStorage.setItem(k,d?\"dark\":\"light\");\
4244 }});\
4245 }})();\
4246 </script>\
4247 </body></html>"
4248 );
4249 return Html(html).into_response();
4250 }
4251 serve_pdf_artifact(
4252 &path,
4253 &artifact_set.report_title,
4254 &run_id,
4255 wants_download,
4256 &csp_nonce,
4257 )
4258 }
4259 "json" => {
4260 let Some(path) = artifact_set.json_path else {
4261 let msg = "JSON result was not generated for this run, or was not recorded in \
4262 the scan registry. Re-run the analysis with JSON output enabled."
4263 .to_string();
4264 let html = ErrorTemplate {
4265 message: msg,
4266 last_report_url: Some("/view-reports".to_string()),
4267 last_report_label: Some("View Reports".to_string()),
4268 csp_nonce: csp_nonce.clone(),
4269 }
4270 .render()
4271 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4272 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4273 };
4274 serve_json_artifact(&path, wants_download, &csp_nonce)
4275 }
4276 "csv" => {
4277 let Some(path) = artifact_set.csv_path else {
4278 let msg = "CSV report was not generated for this run, or was not recorded in \
4279 the scan registry."
4280 .to_string();
4281 let html = ErrorTemplate {
4282 message: msg,
4283 last_report_url: Some(format!("/runs/html/{run_id}")),
4284 last_report_label: Some("View HTML Report".to_string()),
4285 csp_nonce: csp_nonce.clone(),
4286 }
4287 .render()
4288 .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
4289 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4290 };
4291 fs::read(&path).map_or_else(
4292 |_| StatusCode::NOT_FOUND.into_response(),
4293 |bytes| {
4294 let filename = path
4295 .file_name()
4296 .map(|n| n.to_string_lossy().into_owned())
4297 .unwrap_or_else(|| "report.csv".to_string());
4298 (
4299 [
4300 (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
4301 (
4302 header::CONTENT_DISPOSITION,
4303 format!("attachment; filename=\"{filename}\""),
4304 ),
4305 ],
4306 bytes,
4307 )
4308 .into_response()
4309 },
4310 )
4311 }
4312 "xlsx" => {
4313 let Some(path) = artifact_set.xlsx_path else {
4314 let msg = "Excel report was not generated for this run, or was not recorded in \
4315 the scan registry."
4316 .to_string();
4317 let html = ErrorTemplate {
4318 message: msg,
4319 last_report_url: Some(format!("/runs/html/{run_id}")),
4320 last_report_label: Some("View HTML Report".to_string()),
4321 csp_nonce: csp_nonce.clone(),
4322 }
4323 .render()
4324 .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
4325 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4326 };
4327 fs::read(&path).map_or_else(
4328 |_| StatusCode::NOT_FOUND.into_response(),
4329 |bytes| {
4330 let filename = path
4331 .file_name()
4332 .map(|n| n.to_string_lossy().into_owned())
4333 .unwrap_or_else(|| "report.xlsx".to_string());
4334 (
4335 [
4336 (
4337 header::CONTENT_TYPE,
4338 "application/vnd.openxmlformats-officedocument\
4339 .spreadsheetml.sheet"
4340 .to_string(),
4341 ),
4342 (
4343 header::CONTENT_DISPOSITION,
4344 format!("attachment; filename=\"{filename}\""),
4345 ),
4346 ],
4347 bytes,
4348 )
4349 .into_response()
4350 },
4351 )
4352 }
4353 "scan-config" => {
4354 let path = artifact_set
4355 .scan_config_path
4356 .as_deref()
4357 .map(std::path::Path::to_path_buf)
4358 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4359 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4360 fs::read(&path).map_or_else(
4361 |_| StatusCode::NOT_FOUND.into_response(),
4362 |bytes| {
4363 (
4364 [
4365 (
4366 header::CONTENT_TYPE,
4367 "application/json; charset=utf-8".to_string(),
4368 ),
4369 (
4370 header::CONTENT_DISPOSITION,
4371 "attachment; filename=\"scan-config.json\"".to_string(),
4372 ),
4373 ],
4374 bytes,
4375 )
4376 .into_response()
4377 },
4378 )
4379 }
4380 _ if artifact.starts_with("sub_") => {
4381 if artifact.len() > 128
4382 || !artifact
4383 .chars()
4384 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4385 {
4386 return StatusCode::BAD_REQUEST.into_response();
4387 }
4388 let filename = format!("{artifact}.html");
4389 let path = artifact_set.output_dir.join(&filename);
4390 if !path.exists() {
4391 let html = ErrorTemplate {
4392 message: format!(
4393 "Sub-report '{artifact}' was not found in the run directory.\n\
4394 Re-run the analysis with 'Detect and separate git submodules' \
4395 and HTML output enabled."
4396 ),
4397 last_report_url: Some("/view-reports".to_string()),
4398 last_report_label: Some("View Reports".to_string()),
4399 csp_nonce: csp_nonce.clone(),
4400 }
4401 .render()
4402 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4403 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4404 }
4405 serve_html_artifact(&path, wants_download, &csp_nonce)
4406 }
4407 _ => StatusCode::NOT_FOUND.into_response(),
4408 }
4409}
4410
4411struct SubmoduleLinkRow {
4414 name: String,
4415 url: String,
4416}
4417
4418struct HistoryEntryRow {
4419 run_id: String,
4420 run_id_short: String,
4421 timestamp: String,
4422 timestamp_utc_ms: i64,
4423 project_label: String,
4424 project_path: String,
4425 files_analyzed: u64,
4426 files_skipped: u64,
4427 code_lines: u64,
4428 comment_lines: u64,
4429 blank_lines: u64,
4430 git_branch: String,
4431 git_commit: String,
4432 has_html: bool,
4433 has_json: bool,
4434 has_pdf: bool,
4435 submodule_links: Vec<SubmoduleLinkRow>,
4436 submodule_names_csv: String,
4438}
4439
4440fn nth_weekday_of_month(
4442 year: i32,
4443 month: u32,
4444 weekday: chrono::Weekday,
4445 n: u32,
4446) -> chrono::NaiveDate {
4447 use chrono::Datelike;
4448 let mut count = 0u32;
4449 let mut day = 1u32;
4450 loop {
4451 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4452 if d.weekday() == weekday {
4453 count += 1;
4454 if count == n {
4455 return d;
4456 }
4457 }
4458 day += 1;
4459 }
4460}
4461
4462fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4466 use chrono::{Datelike, TimeZone};
4467 let year = dt.year();
4468 let dst_start = chrono::Utc.from_utc_datetime(
4469 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4470 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4471 );
4472 let dst_end = chrono::Utc.from_utc_datetime(
4473 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4474 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4475 );
4476 dt >= dst_start && dt < dst_end
4477}
4478
4479fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4480 if is_pacific_dst(dt) {
4481 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4482 .format("%Y-%m-%d %H:%M PDT")
4483 .to_string()
4484 } else {
4485 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4486 .format("%Y-%m-%d %H:%M PST")
4487 .to_string()
4488 }
4489}
4490
4491fn fmt_git_date(iso: &str) -> Option<String> {
4492 chrono::DateTime::parse_from_rfc3339(iso)
4493 .ok()
4494 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4495}
4496
4497fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4498 reg.entries
4499 .iter()
4500 .map(|e| {
4501 let submodule_links = {
4502 let mut links: Vec<SubmoduleLinkRow> = vec![];
4503 let sub_dir = e
4504 .html_path
4505 .as_ref()
4506 .and_then(|p| p.parent())
4507 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4508 if let Some(dir) = sub_dir {
4509 if let Ok(rd) = std::fs::read_dir(dir) {
4510 for entry_res in rd.flatten() {
4511 let fname = entry_res.file_name();
4512 let fname_str = fname.to_string_lossy();
4513 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4514 let stem = &fname_str[..fname_str.len() - 5];
4515 let display = stem[4..].replace('-', " ");
4516 links.push(SubmoduleLinkRow {
4517 name: display,
4518 url: format!("/runs/{stem}/{}", e.run_id),
4519 });
4520 }
4521 }
4522 }
4523 }
4524 links.sort_by(|a, b| a.name.cmp(&b.name));
4525 links
4526 };
4527 let submodule_names_csv = submodule_links
4528 .iter()
4529 .map(|l| l.name.as_str())
4530 .collect::<Vec<_>>()
4531 .join(",");
4532 HistoryEntryRow {
4533 run_id: e.run_id.clone(),
4534 run_id_short: e
4535 .run_id
4536 .split('-')
4537 .next_back()
4538 .unwrap_or(&e.run_id)
4539 .chars()
4540 .take(7)
4541 .collect(),
4542 timestamp: fmt_la_time(e.timestamp_utc),
4543 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4544 project_label: e.project_label.clone(),
4545 project_path: e
4546 .input_roots
4547 .first()
4548 .map(|s| sanitize_path_str(s))
4549 .unwrap_or_default(),
4550 files_analyzed: e.summary.files_analyzed,
4551 files_skipped: e.summary.files_skipped,
4552 code_lines: e.summary.code_lines,
4553 comment_lines: e.summary.comment_lines,
4554 blank_lines: e.summary.blank_lines,
4555 git_branch: e.git_branch.clone().unwrap_or_default(),
4556 git_commit: e.git_commit.clone().unwrap_or_default(),
4557 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4558 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4559 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4560 submodule_links,
4561 submodule_names_csv,
4562 }
4563 })
4564 .collect()
4565}
4566
4567#[derive(Deserialize, Default)]
4568struct HistoryQuery {
4569 linked: Option<String>,
4570 error: Option<String>,
4571}
4572
4573async fn history_handler(
4574 State(state): State<AppState>,
4575 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4576 Query(query): Query<HistoryQuery>,
4577) -> impl IntoResponse {
4578 auto_scan_watched_dirs(&state).await;
4580 let watched_dirs: Vec<String> = {
4581 let wd = state.watched_dirs.lock().await;
4582 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4583 };
4584 let mut entries = {
4585 let reg = state.registry.lock().await;
4586 make_history_rows(®)
4587 };
4588 entries.retain(|e| e.has_html);
4589 let total_scans = entries.len();
4590 let linked_count = query
4591 .linked
4592 .as_deref()
4593 .and_then(|s| s.parse::<usize>().ok())
4594 .unwrap_or(0);
4595 let browse_error = query.error.filter(|s| !s.is_empty());
4596 let template = HistoryTemplate {
4597 version: env!("CARGO_PKG_VERSION"),
4598 entries,
4599 total_scans,
4600 linked_count,
4601 browse_error,
4602 watched_dirs,
4603 csp_nonce,
4604 };
4605 Html(
4606 template
4607 .render()
4608 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4609 )
4610 .into_response()
4611}
4612
4613async fn compare_select_handler(
4614 State(state): State<AppState>,
4615 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4616) -> impl IntoResponse {
4617 auto_scan_watched_dirs(&state).await;
4618 let watched_dirs: Vec<String> = {
4619 let wd = state.watched_dirs.lock().await;
4620 wd.dirs.iter().map(|p| p.display().to_string()).collect()
4621 };
4622 let mut entries = {
4623 let reg = state.registry.lock().await;
4624 make_history_rows(®)
4625 };
4626 entries.retain(|e| e.has_json);
4627 let total_scans = entries.len();
4628 let template = CompareSelectTemplate {
4629 version: env!("CARGO_PKG_VERSION"),
4630 entries,
4631 total_scans,
4632 watched_dirs,
4633 csp_nonce,
4634 };
4635 Html(
4636 template
4637 .render()
4638 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4639 )
4640 .into_response()
4641}
4642
4643#[derive(Deserialize, Default)]
4646struct CompareQuery {
4647 a: Option<String>,
4648 b: Option<String>,
4649 sub: Option<String>,
4651 scope: Option<String>,
4653}
4654
4655struct CompareFileDeltaRow {
4656 relative_path: String,
4657 language: String,
4658 status: String,
4659 baseline_code: i64,
4660 current_code: i64,
4661 code_delta_str: String,
4662 code_delta_class: String,
4663 comment_delta_str: String,
4664 comment_delta_class: String,
4665 total_delta_str: String,
4666 total_delta_class: String,
4667}
4668
4669fn recompute_summary_from_records(run: &mut AnalysisRun) {
4672 let files_analyzed = run
4673 .per_file_records
4674 .iter()
4675 .filter(|r| r.language.is_some())
4676 .count() as u64;
4677 let code_lines: u64 = run
4678 .per_file_records
4679 .iter()
4680 .map(|r| r.effective_counts.code_lines)
4681 .sum();
4682 let comment_lines: u64 = run
4683 .per_file_records
4684 .iter()
4685 .map(|r| r.effective_counts.comment_lines)
4686 .sum();
4687 let blank_lines: u64 = run
4688 .per_file_records
4689 .iter()
4690 .map(|r| r.effective_counts.blank_lines)
4691 .sum();
4692 run.summary_totals.files_analyzed = files_analyzed;
4693 run.summary_totals.files_considered = files_analyzed;
4694 run.summary_totals.code_lines = code_lines;
4695 run.summary_totals.comment_lines = comment_lines;
4696 run.summary_totals.blank_lines = blank_lines;
4697 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4698}
4699
4700fn fmt_delta(n: i64) -> String {
4701 if n > 0 {
4702 format!("+{n}")
4703 } else {
4704 format!("{n}")
4705 }
4706}
4707
4708fn delta_class(n: i64) -> &'static str {
4709 use std::cmp::Ordering;
4710 match n.cmp(&0) {
4711 Ordering::Greater => "pos",
4712 Ordering::Less => "neg",
4713 Ordering::Equal => "zero",
4714 }
4715}
4716
4717#[allow(clippy::cast_precision_loss)]
4719fn fmt_pct(delta: i64, baseline: u64) -> String {
4720 if baseline == 0 {
4721 return "—".to_string();
4722 }
4723 #[allow(clippy::cast_precision_loss)]
4724 let pct = (delta as f64 / baseline as f64) * 100.0;
4725 if pct > 0.049 {
4726 format!("+{pct:.1}%")
4727 } else if pct < -0.049 {
4728 format!("{pct:.1}%")
4729 } else {
4730 "±0%".to_string()
4731 }
4732}
4733
4734fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4736 prev.map_or_else(
4737 || ("—".to_string(), "na"),
4738 |p| {
4739 #[allow(clippy::cast_possible_wrap)]
4740 let d = curr as i64 - p as i64;
4741 (fmt_delta(d), delta_class(d))
4742 },
4743 )
4744}
4745
4746#[allow(clippy::too_many_lines)]
4747async fn compare_handler(
4748 State(state): State<AppState>,
4750 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4751 Query(query): Query<CompareQuery>,
4752) -> impl IntoResponse {
4753 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4756 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4757 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4758 };
4759
4760 let (maybe_a, maybe_b) = {
4761 let reg = state.registry.lock().await;
4762 (
4763 reg.find_by_run_id(&run_id_a).cloned(),
4764 reg.find_by_run_id(&run_id_b).cloned(),
4765 )
4766 };
4767
4768 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4769 let html = ErrorTemplate {
4770 message: "One or both run IDs were not found in scan history. \
4771 The runs may have been deleted or the registry may have been reset."
4772 .to_string(),
4773 last_report_url: Some("/compare-scans".to_string()),
4774 last_report_label: Some("Compare Scans".to_string()),
4775 csp_nonce: csp_nonce.clone(),
4776 }
4777 .render()
4778 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4779 return Html(html).into_response();
4780 };
4781
4782 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4784 (entry_a, entry_b)
4785 } else {
4786 (entry_b, entry_a)
4787 };
4788
4789 if baseline_entry.run_id != run_id_a {
4793 let canonical = format!(
4794 "/compare?a={}&b={}",
4795 baseline_entry.run_id, current_entry.run_id
4796 );
4797 return axum::response::Redirect::to(&canonical).into_response();
4798 }
4799
4800 let (Some(base_json), Some(curr_json)) = (
4801 baseline_entry.json_path.as_ref(),
4802 current_entry.json_path.as_ref(),
4803 ) else {
4804 let html = ErrorTemplate {
4805 message: "Full comparison requires JSON scan data, which was not saved for one or \
4806 both of these runs. JSON is now always saved for new scans — re-run the \
4807 affected projects to enable comparisons."
4808 .to_string(),
4809 last_report_url: Some("/compare-scans".to_string()),
4810 last_report_label: Some("Compare Scans".to_string()),
4811 csp_nonce: csp_nonce.clone(),
4812 }
4813 .render()
4814 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4815 return Html(html).into_response();
4816 };
4817
4818 let compare_url = format!(
4819 "/compare?a={}&b={}",
4820 baseline_entry.run_id, current_entry.run_id
4821 );
4822
4823 let baseline_run = match read_json(base_json) {
4824 Ok(r) => r,
4825 Err(e) => {
4826 if state.server_mode {
4827 let html = ErrorTemplate {
4828 message: "Could not load baseline scan data. The scan output folder may \
4829 have been moved, renamed, or deleted. Re-running the analysis \
4830 will create fresh comparison data."
4831 .to_string(),
4832 last_report_url: Some("/compare-scans".to_string()),
4833 last_report_label: Some("Compare Scans".to_string()),
4834 csp_nonce: csp_nonce.clone(),
4835 }
4836 .render()
4837 .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
4838 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4839 }
4840 let msg = format!(
4841 "Could not load baseline scan data.\n\nExpected path: {}\n\nError: {e}",
4842 base_json.display()
4843 );
4844 let folder_hint = base_json
4845 .parent()
4846 .map(|p| p.display().to_string())
4847 .unwrap_or_default();
4848 return missing_scan_relocate_response(
4849 &msg,
4850 &baseline_entry.run_id,
4851 &folder_hint,
4852 &compare_url,
4853 false,
4854 &csp_nonce,
4855 );
4856 }
4857 };
4858 let current_run = match read_json(curr_json) {
4859 Ok(r) => r,
4860 Err(e) => {
4861 if state.server_mode {
4862 let html = ErrorTemplate {
4863 message: "Could not load current scan data. The scan output folder may \
4864 have been moved, renamed, or deleted. Re-running the analysis \
4865 will create fresh comparison data."
4866 .to_string(),
4867 last_report_url: Some("/compare-scans".to_string()),
4868 last_report_label: Some("Compare Scans".to_string()),
4869 csp_nonce: csp_nonce.clone(),
4870 }
4871 .render()
4872 .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
4873 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4874 }
4875 let msg = format!(
4876 "Could not load current scan data.\n\nExpected path: {}\n\nError: {e}",
4877 curr_json.display()
4878 );
4879 let folder_hint = curr_json
4880 .parent()
4881 .map(|p| p.display().to_string())
4882 .unwrap_or_default();
4883 return missing_scan_relocate_response(
4884 &msg,
4885 ¤t_entry.run_id,
4886 &folder_hint,
4887 &compare_url,
4888 false,
4889 &csp_nonce,
4890 );
4891 }
4892 };
4893
4894 let active_submodule = query.sub.clone();
4895 let super_scope_active = query.scope.as_deref() == Some("super");
4896
4897 let submodule_options = {
4900 let mut names = std::collections::BTreeSet::new();
4901 for s in &baseline_run.submodule_summaries {
4902 names.insert(s.name.clone());
4903 }
4904 for s in ¤t_run.submodule_summaries {
4905 names.insert(s.name.clone());
4906 }
4907 names.into_iter().collect::<Vec<_>>()
4908 };
4909 let has_any_submodule_data = !submodule_options.is_empty();
4910
4911 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4913 let mut b = baseline_run;
4914 let mut c = current_run;
4915 b.per_file_records
4916 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4917 c.per_file_records
4918 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4919 recompute_summary_from_records(&mut b);
4920 recompute_summary_from_records(&mut c);
4921 (b, c)
4922 } else if super_scope_active {
4923 let mut b = baseline_run;
4924 let mut c = current_run;
4925 b.per_file_records.retain(|f| f.submodule.is_none());
4926 c.per_file_records.retain(|f| f.submodule.is_none());
4927 recompute_summary_from_records(&mut b);
4928 recompute_summary_from_records(&mut c);
4929 (b, c)
4930 } else {
4931 (baseline_run, current_run)
4932 };
4933
4934 let comparison = compute_delta(&effective_baseline, &effective_current);
4935
4936 let file_rows: Vec<CompareFileDeltaRow> = comparison
4937 .file_deltas
4938 .iter()
4939 .map(|d| CompareFileDeltaRow {
4940 relative_path: d.relative_path.clone(),
4941 language: d.language.clone().unwrap_or_else(|| "—".into()),
4942 status: match d.status {
4943 FileChangeStatus::Added => "added".into(),
4944 FileChangeStatus::Removed => "removed".into(),
4945 FileChangeStatus::Modified => "modified".into(),
4946 FileChangeStatus::Unchanged => "unchanged".into(),
4947 },
4948 baseline_code: d.baseline_code,
4949 current_code: d.current_code,
4950 code_delta_str: fmt_delta(d.code_delta),
4951 code_delta_class: delta_class(d.code_delta).into(),
4952 comment_delta_str: fmt_delta(d.comment_delta),
4953 comment_delta_class: delta_class(d.comment_delta).into(),
4954 total_delta_str: fmt_delta(d.total_delta),
4955 total_delta_class: delta_class(d.total_delta).into(),
4956 })
4957 .collect();
4958
4959 let project_path = baseline_entry
4960 .input_roots
4961 .first()
4962 .map(|s| sanitize_path_str(s))
4963 .unwrap_or_default();
4964 let lines_added = sum_added_code_lines(&comparison);
4965 let lines_removed = sum_removed_code_lines(&comparison);
4966 let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
4969 #[allow(clippy::cast_precision_loss)]
4971 let churn_pct = if comparison.summary.baseline_code > 0 {
4972 (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
4973 } else {
4974 0.0
4975 };
4976 #[allow(clippy::cast_precision_loss)]
4977 let scope_flag = new_scope
4978 || (comparison.summary.baseline_code > 0
4979 && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
4980 let s = &comparison.summary;
4981 let template = CompareTemplate {
4982 version: env!("CARGO_PKG_VERSION"),
4983 project_label: baseline_entry.project_label.clone(),
4984 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4985 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4986 baseline_run_id: baseline_entry.run_id.clone(),
4987 current_run_id: current_entry.run_id.clone(),
4988 baseline_run_id_short: baseline_entry
4989 .run_id
4990 .split('-')
4991 .next_back()
4992 .unwrap_or(&baseline_entry.run_id)
4993 .chars()
4994 .take(7)
4995 .collect(),
4996 current_run_id_short: current_entry
4997 .run_id
4998 .split('-')
4999 .next_back()
5000 .unwrap_or(¤t_entry.run_id)
5001 .chars()
5002 .take(7)
5003 .collect(),
5004 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5005 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5006 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5007 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5008 project_path: project_path.clone(),
5009 baseline_code: s.baseline_code,
5010 current_code: s.current_code,
5011 code_lines_delta_str: fmt_delta(s.code_lines_delta),
5012 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5013 baseline_files: s.baseline_files,
5014 current_files: s.current_files,
5015 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5016 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5017 baseline_comments: s.baseline_comments,
5018 current_comments: s.current_comments,
5019 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5020 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5021 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5022 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5023 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5024 code_lines_added: lines_added,
5025 code_lines_removed: lines_removed,
5026 new_scope,
5027 churn_rate_str: if new_scope {
5028 "New".to_string()
5029 } else if s.baseline_code > 0 {
5030 format!("{churn_pct:.1}%")
5031 } else {
5032 "—".to_string()
5033 },
5034 churn_rate_class: if new_scope || churn_pct > 20.0 {
5035 "high".into()
5036 } else if churn_pct > 5.0 {
5037 "med".into()
5038 } else {
5039 "low".into()
5040 },
5041 scope_flag,
5042 files_added: comparison.files_added,
5043 files_removed: comparison.files_removed,
5044 files_modified: comparison.files_modified,
5045 files_unchanged: comparison.files_unchanged,
5046 file_rows,
5047 baseline_git_author: baseline_entry.git_author.clone(),
5048 current_git_author: current_entry.git_author.clone(),
5049 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5050 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5051 baseline_git_tags: baseline_entry.git_tags.clone(),
5052 current_git_tags: current_entry.git_tags.clone(),
5053 baseline_git_commit_date: baseline_entry
5054 .git_commit_date
5055 .as_deref()
5056 .and_then(fmt_git_date),
5057 current_git_commit_date: current_entry
5058 .git_commit_date
5059 .as_deref()
5060 .and_then(fmt_git_date),
5061 project_name: project_path
5062 .rsplit(['/', '\\'])
5063 .find(|s| !s.is_empty())
5064 .unwrap_or(&project_path)
5065 .to_string(),
5066 submodule_options,
5067 has_any_submodule_data,
5068 active_submodule,
5069 super_scope_active,
5070 csp_nonce,
5071 };
5072
5073 Html(
5074 template
5075 .render()
5076 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5077 )
5078 .into_response()
5079}
5080
5081fn format_number(n: u64) -> String {
5089 let s = n.to_string();
5090 let mut out = String::with_capacity(s.len() + s.len() / 3);
5091 let len = s.len();
5092 for (i, c) in s.chars().enumerate() {
5093 if i > 0 && (len - i).is_multiple_of(3) {
5094 out.push(',');
5095 }
5096 out.push(c);
5097 }
5098 out
5099}
5100
5101const fn badge_char_width(c: char) -> f64 {
5102 match c {
5103 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5104 'm' | 'w' => 9.0,
5105 ' ' => 4.0,
5106 _ => 6.5,
5107 }
5108}
5109
5110#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5111fn badge_text_px(text: &str) -> u32 {
5112 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5113}
5114
5115fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5116 let lw = badge_text_px(label) + 20;
5117 let rw = badge_text_px(value) + 20;
5118 let total = lw + rw;
5119 let lx = lw / 2;
5120 let rx = lw + rw / 2;
5121 let le = escape_html(label);
5122 let ve = escape_html(value);
5123 let ce = escape_html(color);
5124 format!(
5125 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
5126 <rect width="{total}" height="20" fill="#555"/>
5127 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
5128 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
5129 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
5130 <text x="{lx}" y="13">{le}</text>
5131 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
5132 <text x="{rx}" y="13">{ve}</text>
5133 </g>
5134</svg>"##
5135 )
5136}
5137
5138#[derive(Deserialize)]
5139struct BadgeQuery {
5140 label: Option<String>,
5141 color: Option<String>,
5142}
5143
5144async fn badge_handler(
5145 State(state): State<AppState>,
5146 AxumPath(metric): AxumPath<String>,
5147 Query(query): Query<BadgeQuery>,
5148) -> Response {
5149 let entry = {
5150 let reg = state.registry.lock().await;
5151 reg.entries.first().cloned()
5152 };
5153
5154 let Some(entry) = entry else {
5155 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
5156 return (
5157 [
5158 (header::CONTENT_TYPE, "image/svg+xml"),
5159 (header::CACHE_CONTROL, "no-cache, max-age=0"),
5160 ],
5161 svg,
5162 )
5163 .into_response();
5164 };
5165
5166 let (default_label, value, default_color) = match metric.as_str() {
5167 "code-lines" => (
5168 "code lines",
5169 format_number(entry.summary.code_lines),
5170 "#4a78ee",
5171 ),
5172 "files" => (
5173 "files analyzed",
5174 format_number(entry.summary.files_analyzed),
5175 "#4a9862",
5176 ),
5177 "comment-lines" => (
5178 "comment lines",
5179 format_number(entry.summary.comment_lines),
5180 "#b35428",
5181 ),
5182 "blank-lines" => (
5183 "blank lines",
5184 format_number(entry.summary.blank_lines),
5185 "#7a5db0",
5186 ),
5187 _ => return StatusCode::NOT_FOUND.into_response(),
5188 };
5189
5190 let label = query.label.as_deref().unwrap_or(default_label);
5191 let color = query.color.as_deref().unwrap_or(default_color);
5192 let svg = render_badge_svg(label, &value, color);
5193
5194 (
5195 [
5196 (header::CONTENT_TYPE, "image/svg+xml"),
5197 (header::CACHE_CONTROL, "no-cache, max-age=0"),
5198 ],
5199 svg,
5200 )
5201 .into_response()
5202}
5203
5204#[derive(Serialize)]
5212struct ApiMetricsResponse {
5213 run_id: String,
5214 timestamp: String,
5215 project: String,
5216 summary: ApiSummaryPayload,
5217 languages: Vec<ApiLanguageRow>,
5218}
5219
5220#[derive(Serialize)]
5221struct ApiSummaryPayload {
5222 files_analyzed: u64,
5223 files_skipped: u64,
5224 code_lines: u64,
5225 comment_lines: u64,
5226 blank_lines: u64,
5227 total_physical_lines: u64,
5228 functions: u64,
5229 classes: u64,
5230 variables: u64,
5231 imports: u64,
5232}
5233
5234#[derive(Serialize)]
5235struct ApiLanguageRow {
5236 name: String,
5237 files: u64,
5238 code_lines: u64,
5239 comment_lines: u64,
5240 blank_lines: u64,
5241 functions: u64,
5242 classes: u64,
5243 variables: u64,
5244 imports: u64,
5245}
5246
5247async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5248 let entry = {
5249 let reg = state.registry.lock().await;
5250 reg.entries.first().cloned()
5251 };
5252 entry.map_or_else(
5253 || {
5254 (
5255 StatusCode::NOT_FOUND,
5256 Json(serde_json::json!({"error": "no scans recorded yet"})),
5257 )
5258 .into_response()
5259 },
5260 |e| build_metrics_response(&e),
5261 )
5262}
5263
5264async fn api_metrics_run_handler(
5265 State(state): State<AppState>,
5266 AxumPath(run_id): AxumPath<String>,
5267) -> Response {
5268 let entry = {
5269 let reg = state.registry.lock().await;
5270 reg.find_by_run_id(&run_id).cloned()
5271 };
5272 entry.map_or_else(
5273 || {
5274 (
5275 StatusCode::NOT_FOUND,
5276 Json(serde_json::json!({"error": "run not found"})),
5277 )
5278 .into_response()
5279 },
5280 |e| build_metrics_response(&e),
5281 )
5282}
5283
5284fn build_metrics_response(entry: &RegistryEntry) -> Response {
5285 let languages: Vec<ApiLanguageRow> = entry
5286 .json_path
5287 .as_ref()
5288 .and_then(|p| read_json(p).ok())
5289 .map(|run| {
5290 run.totals_by_language
5291 .iter()
5292 .map(|l| ApiLanguageRow {
5293 name: l.language.display_name().to_string(),
5294 files: l.files,
5295 code_lines: l.code_lines,
5296 comment_lines: l.comment_lines,
5297 blank_lines: l.blank_lines,
5298 functions: l.functions,
5299 classes: l.classes,
5300 variables: l.variables,
5301 imports: l.imports,
5302 })
5303 .collect()
5304 })
5305 .unwrap_or_default();
5306
5307 let s = &entry.summary;
5308 Json(ApiMetricsResponse {
5309 run_id: entry.run_id.clone(),
5310 timestamp: entry.timestamp_utc.to_rfc3339(),
5311 project: entry.project_label.clone(),
5312 summary: ApiSummaryPayload {
5313 files_analyzed: s.files_analyzed,
5314 files_skipped: s.files_skipped,
5315 code_lines: s.code_lines,
5316 comment_lines: s.comment_lines,
5317 blank_lines: s.blank_lines,
5318 total_physical_lines: s.total_physical_lines,
5319 functions: s.functions,
5320 classes: s.classes,
5321 variables: s.variables,
5322 imports: s.imports,
5323 },
5324 languages,
5325 })
5326 .into_response()
5327}
5328
5329#[derive(Deserialize)]
5336struct ProjectHistoryQuery {
5337 path: Option<String>,
5338}
5339
5340#[derive(Serialize)]
5341struct ProjectHistoryResponse {
5342 scan_count: usize,
5343 last_scan_id: Option<String>,
5344 last_scan_timestamp: Option<String>,
5345 last_scan_code_lines: Option<u64>,
5346 last_git_branch: Option<String>,
5347 last_git_commit: Option<String>,
5348}
5349
5350async fn project_history_handler(
5351 State(state): State<AppState>,
5352 Query(query): Query<ProjectHistoryQuery>,
5353) -> Response {
5354 let path = query.path.unwrap_or_default();
5355 let resolved = resolve_input_path(&path);
5356 let root_str = resolved.to_string_lossy().replace('\\', "/");
5357
5358 let entries: Vec<_> = {
5359 let reg = state.registry.lock().await;
5360 reg.entries
5361 .iter()
5362 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5363 .cloned()
5364 .collect()
5365 };
5366 let scan_count = entries.len();
5367 let last = entries.first();
5368 let last_scan_id = last.map(|e| e.run_id.clone());
5369 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5370 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5371 let last_git_branch = last.and_then(|e| e.git_branch.clone());
5372 let last_git_commit = last.and_then(|e| e.git_commit.clone());
5373
5374 Json(ProjectHistoryResponse {
5375 scan_count,
5376 last_scan_id,
5377 last_scan_timestamp,
5378 last_scan_code_lines,
5379 last_git_branch,
5380 last_git_commit,
5381 })
5382 .into_response()
5383}
5384
5385#[derive(Deserialize)]
5392struct MetricsHistoryQuery {
5393 root: Option<String>,
5394 limit: Option<usize>,
5395 submodule: Option<String>,
5398}
5399
5400#[derive(Serialize)]
5401struct MetricsSubmoduleLink {
5402 name: String,
5403 url: String,
5404}
5405
5406#[derive(Serialize)]
5407struct MetricsHistoryEntry {
5408 run_id: String,
5409 run_id_short: String,
5410 timestamp: String,
5411 commit: Option<String>,
5412 branch: Option<String>,
5413 tags: Vec<String>,
5414 nearest_tag: Option<String>,
5415 code_lines: u64,
5416 comment_lines: u64,
5417 blank_lines: u64,
5418 physical_lines: u64,
5419 files_analyzed: u64,
5420 files_skipped: u64,
5421 test_count: u64,
5422 project_label: String,
5423 html_url: Option<String>,
5424 has_pdf: bool,
5425 submodule_links: Vec<MetricsSubmoduleLink>,
5426}
5427
5428#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
5430 State(state): State<AppState>,
5432 Query(query): Query<MetricsHistoryQuery>,
5433) -> Response {
5434 let limit = query.limit.unwrap_or(50).min(500);
5435 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5436
5437 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5438 let reg = state.registry.lock().await;
5439 reg.entries
5440 .iter()
5441 .filter(|e| {
5442 query.root.as_ref().is_none_or(|root| {
5443 let resolved = resolve_input_path(root);
5444 let root_str = resolved.to_string_lossy().replace('\\', "/");
5445 e.input_roots.iter().any(|r| r == &root_str)
5446 })
5447 })
5448 .take(limit)
5449 .cloned()
5450 .collect()
5451 };
5452
5453 let entries: Vec<MetricsHistoryEntry> = candidate_entries
5454 .into_iter()
5455 .filter_map(|e| {
5456 let tags = e
5457 .git_tags
5458 .as_deref()
5459 .map(|s| {
5460 s.split(',')
5461 .map(|t| t.trim().to_string())
5462 .filter(|t| !t.is_empty())
5463 .collect()
5464 })
5465 .unwrap_or_default();
5466 let html_url = e
5467 .html_path
5468 .as_ref()
5469 .filter(|p| p.exists())
5470 .map(|_| format!("/runs/html/{}", e.run_id));
5471 let nearest_tag = e.git_nearest_tag.clone();
5472 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5473 let run_id_short: String = e
5474 .run_id
5475 .split('-')
5476 .next_back()
5477 .unwrap_or(&e.run_id)
5478 .chars()
5479 .take(7)
5480 .collect();
5481 let submodule_links: Vec<MetricsSubmoduleLink> = {
5482 let mut links: Vec<MetricsSubmoduleLink> = vec![];
5483 let sub_dir = e
5484 .html_path
5485 .as_ref()
5486 .and_then(|p| p.parent())
5487 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5488 if let Some(dir) = sub_dir {
5489 if let Ok(rd) = std::fs::read_dir(dir) {
5490 for entry_res in rd.flatten() {
5491 let fname = entry_res.file_name();
5492 let fname_str = fname.to_string_lossy();
5493 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5494 let stem = &fname_str[..fname_str.len() - 5];
5495 let display = stem[4..].replace('-', " ");
5496 links.push(MetricsSubmoduleLink {
5497 name: display,
5498 url: format!("/runs/{stem}/{}", e.run_id),
5499 });
5500 }
5501 }
5502 }
5503 }
5504 links.sort_by(|a, b| a.name.cmp(&b.name));
5505 links
5506 };
5507 let base = MetricsHistoryEntry {
5508 run_id: e.run_id.clone(),
5509 run_id_short,
5510 timestamp: e.timestamp_utc.to_rfc3339(),
5511 commit: e.git_commit.clone(),
5512 branch: e.git_branch.clone(),
5513 tags,
5514 nearest_tag,
5515 code_lines: e.summary.code_lines,
5516 comment_lines: e.summary.comment_lines,
5517 blank_lines: e.summary.blank_lines,
5518 physical_lines: e.summary.total_physical_lines,
5519 files_analyzed: e.summary.files_analyzed,
5520 files_skipped: e.summary.files_skipped,
5521 test_count: e.summary.test_count,
5522 project_label: e.project_label.clone(),
5523 html_url,
5524 has_pdf,
5525 submodule_links,
5526 };
5527 if let Some(ref filter) = submodule_filter {
5528 let json_path = e.json_path.as_ref()?;
5530 let json_str = std::fs::read_to_string(json_path).ok()?;
5531 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5532 let sub = run.submodule_summaries.iter().find(|s| {
5533 s.name.to_lowercase() == *filter || s.relative_path.to_lowercase() == *filter
5534 })?;
5535 let safe = sanitize_project_label(&sub.name);
5537 let artifact_key = format!("sub_{safe}");
5538 let sub_html_url = if let Some(run_dir) = std::path::Path::new(json_path).parent() {
5539 let sub_path = run_dir.join(format!("{artifact_key}.html"));
5540 if sub_path.exists() {
5541 Some(format!("/runs/{artifact_key}/{}", e.run_id))
5542 } else {
5543 base.html_url.clone()
5544 }
5545 } else {
5546 base.html_url.clone()
5547 };
5548 Some(MetricsHistoryEntry {
5549 code_lines: sub.code_lines,
5550 comment_lines: sub.comment_lines,
5551 blank_lines: sub.blank_lines,
5552 physical_lines: sub.total_physical_lines,
5553 files_analyzed: sub.files_analyzed,
5554 html_url: sub_html_url,
5555 has_pdf: false,
5556 submodule_links: vec![],
5557 ..base
5558 })
5559 } else {
5560 Some(base)
5561 }
5562 })
5563 .collect();
5564
5565 Json(entries).into_response()
5566}
5567
5568#[derive(Deserialize)]
5572struct MetricsSubmodulesQuery {
5573 root: Option<String>,
5574}
5575
5576#[derive(Serialize)]
5577struct SubmoduleEntry {
5578 name: String,
5579 relative_path: String,
5580}
5581
5582async fn api_metrics_submodules_handler(
5583 State(state): State<AppState>,
5584 Query(query): Query<MetricsSubmodulesQuery>,
5585) -> Response {
5586 let json_paths: Vec<std::path::PathBuf> = {
5587 let reg = state.registry.lock().await;
5588 reg.entries
5589 .iter()
5590 .filter(|e| {
5591 query.root.as_ref().is_none_or(|root| {
5592 let resolved = resolve_input_path(root);
5593 let root_str = resolved.to_string_lossy().replace('\\', "/");
5594 e.input_roots.iter().any(|r| r == &root_str)
5595 })
5596 })
5597 .filter_map(|e| e.json_path.clone())
5598 .collect()
5599 };
5600
5601 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5602 let mut result: Vec<SubmoduleEntry> = Vec::new();
5603
5604 for path in &json_paths {
5605 let Ok(json_str) = std::fs::read_to_string(path) else {
5606 continue;
5607 };
5608 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5609 continue;
5610 };
5611 for sub in &run.submodule_summaries {
5612 if seen.insert(sub.name.clone()) {
5613 result.push(SubmoduleEntry {
5614 name: sub.name.clone(),
5615 relative_path: sub.relative_path.clone(),
5616 });
5617 }
5618 }
5619 }
5620
5621 result.sort_by(|a, b| a.name.cmp(&b.name));
5622 Json(result).into_response()
5623}
5624
5625#[derive(Deserialize)]
5634struct IngestQuery {
5635 label: Option<String>,
5636}
5637
5638async fn api_ingest_handler(
5639 State(state): State<AppState>,
5640 Query(q): Query<IngestQuery>,
5641 Json(run): Json<sloc_core::AnalysisRun>,
5642) -> Response {
5643 let label = q.label.unwrap_or_else(|| {
5644 run.input_roots
5645 .first()
5646 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5647 });
5648
5649 let label_for_task = label.clone();
5650 let result = tokio::task::spawn_blocking(move || {
5651 let html = render_html(&run)?;
5652 let run_id = run.tool.run_id.clone();
5653 let run_id_safe = run_id.len() <= 128
5654 && !run_id.is_empty()
5655 && run_id
5656 .chars()
5657 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5658 if !run_id_safe {
5659 anyhow::bail!(
5660 "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5661 );
5662 }
5663 let project_label = sanitize_project_label(&label_for_task);
5664 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5665 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5666 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5667 _ => project_label,
5668 };
5669 let (artifacts, _pending_pdf) = persist_run_artifacts(
5670 &run,
5671 &html,
5672 &output_dir,
5673 true,
5674 true,
5675 false,
5676 &label_for_task,
5677 &file_stem,
5678 RunResultContext::default(),
5679 )?;
5680 Ok::<_, anyhow::Error>((run_id, artifacts, run))
5681 })
5682 .await;
5683
5684 match result {
5685 Ok(Ok((run_id, artifacts, run))) => {
5686 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5687 (
5688 StatusCode::CREATED,
5689 Json(serde_json::json!({
5690 "run_id": run_id,
5691 "view_url": format!("/view-reports?run_id={run_id}"),
5692 })),
5693 )
5694 .into_response()
5695 }
5696 Ok(Err(e)) => (
5697 StatusCode::INTERNAL_SERVER_ERROR,
5698 Json(serde_json::json!({"error": format!("{e:#}")})),
5699 )
5700 .into_response(),
5701 Err(e) => (
5702 StatusCode::INTERNAL_SERVER_ERROR,
5703 Json(serde_json::json!({"error": format!("{e}")})),
5704 )
5705 .into_response(),
5706 }
5707}
5708
5709#[allow(clippy::too_many_lines)] async fn trend_report_handler(
5717 State(state): State<AppState>,
5719 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5720) -> Response {
5721 auto_scan_watched_dirs(&state).await;
5722
5723 let watched_dirs_list: Vec<String> = {
5724 let wd = state.watched_dirs.lock().await;
5725 wd.dirs.iter().map(|p| p.display().to_string()).collect()
5726 };
5727
5728 let roots: Vec<String> = {
5730 let reg = state.registry.lock().await;
5731 let mut seen = std::collections::BTreeSet::new();
5732 reg.entries
5733 .iter()
5734 .flat_map(|e| e.input_roots.iter().cloned())
5735 .filter(|r| seen.insert(r.clone()))
5736 .collect()
5737 };
5738
5739 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5740 let nonce = &csp_nonce;
5741 let version = env!("CARGO_PKG_VERSION");
5742
5743 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5745 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5746 .to_string()
5747 } else {
5748 watched_dirs_list
5749 .iter()
5750 .fold(String::new(), |mut s, d| {
5751 use std::fmt::Write as _;
5752 let escaped = d.replace('&', "&").replace('"', """).replace('<', "<");
5753 write!(
5754 s,
5755 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>"#
5756 ).expect("write to String is infallible");
5757 s
5758 })
5759 };
5760 let watched_dirs_html = format!(
5761 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>"#
5762 );
5763
5764 let html = format!(
5765 r##"<!doctype html>
5766<html lang="en">
5767<head>
5768 <meta charset="utf-8" />
5769 <meta name="viewport" content="width=device-width, initial-scale=1" />
5770 <title>OxideSLOC | Trend Reports</title>
5771 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5772 <style nonce="{nonce}">
5773 :root {{
5774 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5775 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5776 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5777 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5778 --info-bg:#eef3ff; --info-text:#4467d8;
5779 }}
5780 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5781 *{{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);}}
5782 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5783 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5784 .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;}}
5785 @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));}}}}
5786 .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);}}
5787 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5788 .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));}}
5789 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5790 .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;}}
5791 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5792 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5793 @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; }} }}
5794 .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;}}
5795 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5796 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5797 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5798 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5799 .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;}}
5800 .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;}}
5801 .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;}}
5802 .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;}}
5803 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5804 .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);}}
5805 .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;}}
5806 .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;}}
5807 .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;}}
5808 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5809 .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;}}
5810 .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);}}
5811 .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;}}
5812 .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;}}
5813 .tz-select:focus{{border-color:var(--oxide);}}
5814 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5815 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5816 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5817 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5818 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5819 .trend-title-block{{flex:1;min-width:0;}}
5820 .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;}}
5821 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5822 .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;}}
5823 .chart-select:focus{{border-color:var(--accent);}}
5824 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5825 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5826 .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;}}
5827 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5828 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5829 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5830 .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);}}
5831 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5832 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5833 .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;}}
5834 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5835 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5836 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5837 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5838 .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;}}
5839 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5840 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5841 .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);}}
5842 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5843 .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;}}
5844 .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;}}
5845 .data-table tr:last-child td{{border-bottom:none;}}
5846 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5847 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5848 .table-wrap{{width:100%;overflow-x:auto;}}
5849 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5850 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5851 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5852 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5853 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5854 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5855 .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;}}
5856 .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;}}
5857 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5858 .pagination-info{{font-size:13px;color:var(--muted);}}
5859 .pagination-btns{{display:flex;gap:6px;}}
5860 .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;}}
5861 .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;}}
5862 #scan-history-table col:nth-child(1){{width:155px;}}
5863 #scan-history-table col:nth-child(2){{width:240px;}}
5864 #scan-history-table col:nth-child(3){{width:82px;}}
5865 #scan-history-table col:nth-child(4){{width:82px;}}
5866 #scan-history-table col:nth-child(5){{width:90px;}}
5867 #scan-history-table col:nth-child(6){{width:90px;}}
5868 #scan-history-table col:nth-child(7){{width:88px;}}
5869 #scan-history-table col:nth-child(8){{width:150px;}}
5870 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5871 .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;}}
5872 .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;}}
5873 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5874 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5875 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5876 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5877 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5878 .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;}}
5879 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5880 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5881 .watched-chip-rm:hover{{color:var(--oxide);}}
5882 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5883 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5884 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
5885 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5886 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5887 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5888 a.run-link:hover{{text-decoration:underline;}}
5889 .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);}}
5890 .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);}}
5891 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5892 .metric-num{{font-weight:700;color:var(--text);}}
5893 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5894 .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;}}
5895 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5896 .btn.primary:hover{{opacity:.9;}}
5897 .rpt-btn{{min-width:58px;justify-content:center;}}
5898 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5899 .report-cell{{overflow:visible!important;white-space:normal!important;}}
5900 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5901 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5902 .submod-details summary::-webkit-details-marker{{display:none;}}
5903 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5904 .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;}}
5905 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5906 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5907 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5908 .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;}}
5909 .export-btn:hover{{background:var(--line);}}
5910 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5911 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5912 .site-footer a{{color:var(--muted);}}
5913 .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;}}
5914 .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;}}
5915 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5916 </style>
5917</head>
5918<body>
5919 <div class="background-watermarks" aria-hidden="true">
5920 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5921 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5922 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5923 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5924 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5925 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5926 </div>
5927 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5928 <div class="top-nav">
5929 <div class="top-nav-inner">
5930 <a class="brand" href="/">
5931 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5932 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5933 </a>
5934 <div class="nav-right">
5935 <a class="nav-pill" href="/">Home</a>
5936 <div class="nav-dropdown">
5937 <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>
5938 <div class="nav-dropdown-menu">
5939 <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>
5940 </div>
5941 </div>
5942 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5943 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5944 <div class="nav-dropdown">
5945 <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>
5946 <div class="nav-dropdown-menu">
5947 <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>
5948 </div>
5949 </div>
5950 <div class="server-status-wrap">
5951 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5952 <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>
5953 </div>
5954 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5955 <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>
5956 </button>
5957 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5958 <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>
5959 <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>
5960 </button>
5961 </div>
5962 </div>
5963 </div>
5964
5965 <div class="page">
5966 {watched_dirs_html}
5967 <div class="summary-strip" id="trend-stats"></div>
5968 <div class="panel">
5969 <div class="trend-header">
5970 <div class="trend-title-block">
5971 <h1>Trend Reports</h1>
5972 <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>
5973 <span class="chart-hint-inline">
5974 <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>
5975 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
5976 </span>
5977 </div>
5978 <div class="chart-actions">
5979 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5980 <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>
5981 Export Excel
5982 </button>
5983 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5984 <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>
5985 Export PNG
5986 </button>
5987 </div>
5988 </div>
5989
5990 <div class="controls-centered">
5991 <label>Project Root:
5992 <select class="chart-select" id="root-sel">
5993 <option value="">All projects</option>
5994 </select>
5995 </label>
5996 <label>Y Metric:
5997 <select class="chart-select" id="y-sel">
5998 <option value="code_lines">Code Lines</option>
5999 <option value="comment_lines">Comment Lines</option>
6000 <option value="blank_lines">Blank Lines</option>
6001 <option value="physical_lines">Physical Lines</option>
6002 <option value="files_analyzed">Files Analyzed</option>
6003 </select>
6004 </label>
6005 <label>X Axis:
6006 <select class="chart-select" id="x-sel">
6007 <option value="time">By Time</option>
6008 <option value="commit">By Commit</option>
6009 <option value="release">By Release</option>
6010 <option value="tag">Tagged Commits</option>
6011 </select>
6012 </label>
6013 <label id="submodule-label" style="display:none;">Submodule:
6014 <select class="chart-select" id="sub-sel">
6015 <option value="">All (project total)</option>
6016 </select>
6017 </label>
6018 <label>Chart Size:
6019 <select class="chart-select" id="scale-sel">
6020 <option value="0.75">Compact</option>
6021 <option value="1.2" selected>Normal</option>
6022 <option value="1.38">Large</option>
6023 </select>
6024 </label>
6025 </div>
6026
6027 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
6028 <div id="data-table-wrap" style="overflow-x:auto;"></div>
6029 </div>
6030 </div>
6031
6032 <script nonce="{nonce}">
6033 (function() {{
6034 // Theme persistence
6035 var b = document.body;
6036 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
6037 var tgl = document.getElementById('theme-toggle');
6038 if (tgl) tgl.addEventListener('click', function() {{
6039 var d = b.classList.toggle('dark-theme');
6040 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
6041 }});
6042
6043 // Watermark randomizer
6044 (function() {{
6045 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6046 if (!wms.length) return;
6047 var placed = [];
6048 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;}}
6049 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];}}
6050 var half=Math.floor(wms.length/2);
6051 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;}});
6052 }})();
6053
6054 // Code particles
6055 (function() {{
6056 var container = document.getElementById('code-particles');
6057 if (!container) return;
6058 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'];
6059 for (var i = 0; i < 38; i++) {{
6060 (function(idx) {{
6061 var el = document.createElement('span');
6062 el.className = 'code-particle';
6063 el.textContent = snippets[idx % snippets.length];
6064 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
6065 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
6066 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
6067 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';
6068 container.appendChild(el);
6069 }})(i);
6070 }}
6071 }})();
6072
6073 // Watched folder picker
6074 (function() {{
6075 var btn = document.getElementById('add-watched-btn');
6076 if (!btn) return;
6077 btn.addEventListener('click', function() {{
6078 fetch('/pick-directory?kind=reports')
6079 .then(function(r) {{ return r.json(); }})
6080 .then(function(data) {{
6081 if (!data.cancelled && data.selected_path) {{
6082 var form = document.createElement('form');
6083 form.method = 'POST';
6084 form.action = '/watched-dirs/add';
6085 var ri = document.createElement('input');
6086 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
6087 var fi = document.createElement('input');
6088 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
6089 form.appendChild(ri); form.appendChild(fi);
6090 document.body.appendChild(form);
6091 form.submit();
6092 }}
6093 }})
6094 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
6095 }});
6096 }})();
6097
6098 // Settings / color-scheme modal
6099 (function() {{
6100 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'}}];
6101 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);}});}}
6102 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
6103 var btn=document.getElementById('settings-btn');if(!btn)return;
6104 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
6105 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>';
6106 document.body.appendChild(m);
6107 var g=document.getElementById('scheme-grid');
6108 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);}});
6109 var cl=document.getElementById('settings-close');
6110 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);
6111 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');}});
6112 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
6113 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
6114 }})();
6115 }})();
6116
6117 var ROOTS = {roots_json};
6118 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
6119 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
6120 var allData = [];
6121
6122 // Populate root selector
6123 var rootSel = document.getElementById('root-sel');
6124 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
6125
6126 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();}}
6127 function fmtFull(n){{return Number(n).toLocaleString();}}
6128 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
6129
6130 // Tooltip
6131 var tt = document.createElement('div');
6132 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);';
6133 document.body.appendChild(tt);
6134 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
6135 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';}}
6136 function hideTT(){{tt.style.display='none';}}
6137
6138 function statExact(compact, full){{
6139 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
6140 }}
6141 function statVal(n){{
6142 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
6143 }}
6144
6145 function updateStats(data){{
6146 var statsEl=document.getElementById('trend-stats');
6147 if(!statsEl)return;
6148 if(!data||!data.length){{statsEl.innerHTML='';return;}}
6149 var yKey=document.getElementById('y-sel').value;
6150 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6151 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6152 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
6153 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
6154 var absDelta=Math.abs(delta);
6155 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
6156 var deltaExact=statExact(deltaCompact,deltaFull);
6157 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
6158 statsEl.innerHTML=
6159 '<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>'+
6160 '<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>'+
6161 '<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>'+
6162 '<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>';
6163 }}
6164
6165 var subSel = document.getElementById('sub-sel');
6166 var subLabel = document.getElementById('submodule-label');
6167
6168 function populateSubmodules(root){{
6169 if(!subSel||!subLabel)return;
6170 while(subSel.options.length>1)subSel.remove(1);
6171 subSel.value='';
6172 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
6173 fetch(url)
6174 .then(function(r){{return r.json();}})
6175 .then(function(subs){{
6176 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
6177 subs.forEach(function(s){{
6178 var o=document.createElement('option');
6179 o.value=s.name;
6180 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
6181 subSel.appendChild(o);
6182 }});
6183 subLabel.style.display='';
6184 }})
6185 .catch(function(){{subLabel.style.display='none';}});
6186 }}
6187
6188 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
6189
6190 function loadAndRender(){{
6191 var root = rootSel.value;
6192 var sub = subSel ? subSel.value : '';
6193 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
6194 document.getElementById('data-table-wrap').innerHTML='';
6195 var url = '/api/metrics/history?limit=100'
6196 + (root ? '&root='+encodeURIComponent(root) : '')
6197 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
6198 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
6199 allData = data;
6200 render(data);
6201 updateStats(data);
6202 }}).catch(function(){{
6203 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>';
6204 }});
6205 }}
6206
6207 function render(data){{
6208 var yKey = document.getElementById('y-sel').value;
6209 var xMode = document.getElementById('x-sel').value;
6210
6211 // Filter for tag/release mode
6212 var pts = data;
6213 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
6214
6215 // Sort oldest-first for the line chart
6216 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6217
6218 var wrap = document.getElementById('chart-wrap');
6219 if(!pts.length){{
6220 var emptyMsg = (xMode === 'tag')
6221 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
6222 : 'No scan data found for the selected filters.';
6223 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
6224 renderTable([]);
6225 return;
6226 }}
6227
6228 var scaleEl=document.getElementById('scale-sel');
6229 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
6230 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;
6231 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
6232
6233 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6234
6235 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">';
6236 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>';
6237
6238 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
6239
6240 // Grid + Y axis ticks
6241 for(var ti=0;ti<=5;ti++){{
6242 var gy=PT+CH-Math.round(ti/5*CH);
6243 var gv=Math.round(ti/5*maxY);
6244 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
6245 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
6246 }}
6247
6248 // X axis labels (every N-th point to avoid crowding)
6249 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6250 pts.forEach(function(d,i){{
6251 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6252 if(i%labelEvery===0||i===pts.length-1){{
6253 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)));
6254 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>';
6255 }}
6256 }});
6257
6258 // Axis label
6259 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6260 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>';
6261 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>';
6262
6263 // Area fill + line path
6264 var pathD='';
6265 pts.forEach(function(d,i){{
6266 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6267 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6268 pathD+=(i===0?'M':'L')+x+','+y;
6269 }});
6270 if(pts.length>1){{
6271 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6272 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6273 }}
6274 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6275
6276 // Data points (clickable) + permanent value labels
6277 var showLabels = pts.length <= 40;
6278 var labelEveryN = pts.length > 20 ? 2 : 1;
6279 pts.forEach(function(d,i){{
6280 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6281 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6282 var hasTags=d.tags&&d.tags.length>0;
6283 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6284 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6285 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+'"/>';
6286 if(showLabels && i%labelEveryN===0){{
6287 var lx=x, ly=y-r-5;
6288 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>';
6289 }}
6290 }});
6291
6292 svg+='</svg>';
6293 wrap.innerHTML=svg;
6294
6295 // Attach point tooltips
6296 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6297 c.addEventListener('mouseover',function(e){{
6298 var d=pts[parseInt(this.dataset.idx)];
6299 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(''):'';
6300 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>':'';
6301 showTT(e,
6302 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6303 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6304 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6305 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6306 );
6307 this.setAttribute('r','8');
6308 }});
6309 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6310 c.addEventListener('mousemove',moveTT);
6311 c.addEventListener('click',function(){{
6312 var d=pts[parseInt(this.dataset.idx)];
6313 if(d.html_url) window.open(d.html_url,'_blank');
6314 }});
6315 }});
6316
6317 renderTable(pts, yKey);
6318 }}
6319
6320 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6321 var shProjFilter='', shBranchFilter='';
6322
6323 function fmtPST(isoStr){{
6324 if(!isoStr)return'';
6325 var d=new Date(isoStr);
6326 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6327 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);}}
6328 function p(n){{return n<10?'0'+n:String(n);}}
6329 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++;}}}}
6330 var yr=d.getUTCFullYear();
6331 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6332 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6333 var isDST=d>=dstStart&&d<dstEnd;
6334 var off=isDST?-7*3600*1000:-8*3600*1000;
6335 var lbl=isDST?'PDT':'PST';
6336 var loc=new Date(d.getTime()+off);
6337 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6338 }}
6339
6340 function getShRows(){{
6341 var proj=shProjFilter.toLowerCase().trim();
6342 var branch=shBranchFilter;
6343 return shData.filter(function(d){{
6344 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6345 if(branch&&(d.branch||'')!==branch)return false;
6346 return true;
6347 }});
6348 }}
6349
6350 function renderShPage(){{
6351 var filtered=getShRows();
6352 if(shSortCol){{
6353 filtered.sort(function(a,b){{
6354 var va,vb;
6355 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6356 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6357 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6358 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6359 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6360 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6361 }});
6362 }}
6363 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6364 shPage=Math.min(shPage,totalPages);
6365 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6366 var visible=filtered.slice(start,end);
6367 var tbody=document.getElementById('sh-tbody');
6368 if(!tbody)return;
6369 tbody.innerHTML=visible.map(function(d){{
6370 var tsHtml=esc(fmtPST(d.timestamp));
6371 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>';
6372 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>';
6373 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
6374 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
6375 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6376 var reportCell='';
6377 if(d.html_url){{
6378 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6379 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>';}}
6380 reportCell+='</div>';
6381 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
6382 if(d.submodule_links&&d.submodule_links.length){{
6383 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6384 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6385 reportCell+='</div></details>';
6386 }}
6387 return '<tr>'
6388 +'<td>'+tsHtml+'</td>'
6389 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6390 +'<td>'+runIdHtml+'</td>'
6391 +'<td>'+commitHtml+'</td>'
6392 +'<td>'+branchHtml+'</td>'
6393 +'<td>'+tags+'</td>'
6394 +'<td class="num">'+metricHtml+'</td>'
6395 +'<td class="report-cell">'+reportCell+'</td>'
6396 +'</tr>';
6397 }}).join('');
6398 var pgRange=document.getElementById('sh-pg-range');
6399 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6400 var pgInfo=document.getElementById('sh-pg-info');
6401 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6402 var pgBtns=document.getElementById('sh-pg-btns');
6403 if(pgBtns){{
6404 pgBtns.innerHTML='';
6405 function mkPgBtn(lbl,pg,active,disabled){{
6406 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6407 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6408 return b;
6409 }}
6410 pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6411 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6412 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6413 pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6414 }}
6415 }}
6416
6417 function wireTableBehavior(){{
6418 var pf=document.getElementById('sh-proj-filter');
6419 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6420 var bf=document.getElementById('sh-branch-filter');
6421 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6422 var rb=document.getElementById('sh-reset-btn');
6423 if(rb)rb.addEventListener('click',function(){{
6424 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6425 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6426 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6427 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');}});
6428 renderShPage();
6429 }});
6430 var pps=document.getElementById('sh-per-page');
6431 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6432 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6433 ths.forEach(function(th){{
6434 th.addEventListener('click',function(e){{
6435 if(e.target.classList.contains('col-resize-handle'))return;
6436 var col=th.dataset.col;
6437 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6438 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6439 th.classList.add('sort-'+shSortOrder);
6440 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6441 shPage=1;renderShPage();
6442 }});
6443 }});
6444 var table=document.getElementById('scan-history-table');
6445 if(!table)return;
6446 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6447 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6448 allThs.forEach(function(th,i){{
6449 var handle=th.querySelector('.col-resize-handle');
6450 if(!handle||!cols[i])return;
6451 var startX,startW;
6452 handle.addEventListener('mousedown',function(e){{
6453 e.stopPropagation();e.preventDefault();
6454 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6455 handle.classList.add('dragging');
6456 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6457 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6458 document.addEventListener('mousemove',onMove);
6459 document.addEventListener('mouseup',onUp);
6460 }});
6461 }});
6462 }}
6463
6464 function renderTable(pts, yKey){{
6465 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6466 var wrap=document.getElementById('data-table-wrap');
6467 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6468 var yLabel=Y_LABELS[yKey]||yKey||'';
6469 shData=pts.slice().reverse();
6470 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6471 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6472 var branches={{}};
6473 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6474 var branchOpts='<option value="">All branches</option>';
6475 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6476 wrap.innerHTML=
6477 '<div class="chart-section-header">SCAN HISTORY</div>'+
6478 '<div class="filter-row">'+
6479 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6480 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6481 '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6482 '</div>'+
6483 '<div class="table-wrap">'+
6484 '<table id="scan-history-table" class="data-table">'+
6485 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6486 '<thead><tr id="sh-thead">'+
6487 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6488 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6489 '<th>Run ID<div class="col-resize-handle"></div></th>'+
6490 '<th>Commit<div class="col-resize-handle"></div></th>'+
6491 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6492 '<th>Tags<div class="col-resize-handle"></div></th>'+
6493 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
6494 '<th>Report<div class="col-resize-handle"></div></th>'+
6495 '</tr></thead>'+
6496 '<tbody id="sh-tbody"></tbody>'+
6497 '</table>'+
6498 '</div>'+
6499 '<div class="pagination">'+
6500 '<span class="pagination-info" id="sh-pg-info"></span>'+
6501 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6502 '<div style="display:flex;align-items:center;gap:8px;">'+
6503 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6504 '<select class="filter-select" id="sh-per-page">'+
6505 '<option value="10">10 per page</option>'+
6506 '<option value="25" selected>25 per page</option>'+
6507 '<option value="50">50 per page</option>'+
6508 '<option value="100">100 per page</option>'+
6509 '</select>'+
6510 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6511 '</div>'+
6512 '</div>';
6513 wireTableBehavior();
6514 renderShPage();
6515 }}
6516
6517 function exportXLSX(){{
6518 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6519 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6520 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6521 var s1R=sorted.map(function(d){{
6522 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||''];
6523 }});
6524 var pm={{}};
6525 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6526 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'];
6527 var s2R=Object.keys(pm).map(function(p){{
6528 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6529 var lat=sc[sc.length-1],fst=sc[0];
6530 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6531 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);
6532 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];
6533 }});
6534 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6535 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6536 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6537 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6538 }}
6539
6540 function buildXLSX(sheets,chartRows,chartRows2){{
6541 function s2b(s){{return new TextEncoder().encode(s);}}
6542 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
6543 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;}}
6544 function crc32(d){{
6545 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;}}}}
6546 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6547 }}
6548 function buildSheet(hdr,rows,drawRid,withCtrl){{
6549 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6550 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6551 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6552 x+='<row r="1">';
6553 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6554 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>';}}
6555 x+='</row>';
6556 rows.forEach(function(row,ri){{
6557 var rn=ri+2;
6558 x+='<row r="'+rn+'">';
6559 row.forEach(function(cell,ci){{
6560 var addr=col2l(ci+1)+rn;
6561 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6562 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6563 }});
6564 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>';}}
6565 x+='</row>';
6566 }});
6567 x+='</sheetData>';
6568 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>';}}
6569 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6570 return x+'</worksheet>';
6571 }}
6572 function buildChartXML(rows){{
6573 var sn="'Scan History'";
6574 var nr=rows.length,er=nr+1;
6575 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'}}];
6576 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6577 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">';
6578 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6579 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6580 sd.forEach(function(s,i){{
6581 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6582 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>';
6583 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6584 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>';
6585 var dlp=(i===2)?'b':'t';
6586 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>';
6587 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6588 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6589 x+='</c:strCache></c:strRef></c:cat>';
6590 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+'"/>';
6591 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6592 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6593 }});
6594 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6595 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>';
6596 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>';
6597 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6598 return x;
6599 }}
6600 function buildChartXML2(rows){{
6601 var sn="'By Project'";
6602 var nr=rows.length,er=nr+1;
6603 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'}}];
6604 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6605 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">';
6606 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6607 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6608 sd.forEach(function(s,i){{
6609 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6610 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>';
6611 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6612 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>';
6613 var dlp=(i===2)?'b':'t';
6614 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>';
6615 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6616 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6617 x+='</c:strCache></c:strRef></c:cat>';
6618 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+'"/>';
6619 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6620 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6621 }});
6622 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6623 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>';
6624 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>';
6625 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6626 return x;
6627 }}
6628 function buildChartXML3(rows){{
6629 var sn="'Scan History'";
6630 var nr=rows.length,er=nr+1;
6631 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6632 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">';
6633 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6634 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6635 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6636 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>';
6637 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6638 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>';
6639 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>';
6640 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6641 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6642 x+='</c:strCache></c:strRef></c:cat>';
6643 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+'"/>';
6644 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6645 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6646 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6647 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>';
6648 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>';
6649 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>';
6650 return x;
6651 }}
6652 var hasChart=!!(chartRows&&chartRows.length);
6653 var nr=hasChart?chartRows.length:0;
6654 var hasChart2=!!(chartRows2&&chartRows2.length);
6655 var nr2=hasChart2?chartRows2.length:0;
6656 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>';
6657 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"/>';
6658 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6659 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"/>';}}
6660 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"/>';}}
6661 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6662 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>';
6663 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6664 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"/>';}});
6665 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6666 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>';
6667 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6668 wbx+='</sheets></workbook>';
6669 var files=[
6670 {{name:'[Content_Types].xml',data:s2b(ct)}},
6671 {{name:'_rels/.rels',data:s2b(dotrels)}},
6672 {{name:'xl/workbook.xml',data:s2b(wbx)}},
6673 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6674 {{name:'xl/styles.xml',data:s2b(styl)}}
6675 ];
6676 // Chart embedded directly in Scan History (sheet1); By Project is plain
6677 sheets.forEach(function(s,i){{
6678 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)))}});
6679 }});
6680 if(hasChart){{
6681 var fromRow=nr+4,toRow=nr+24;
6682 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>')}});
6683 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6684 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">';
6685 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6686 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>';
6687 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>';
6688 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6689 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6690 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6691 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6692 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6693 var focRow=toRow+2,focRowEnd=toRow+22;
6694 drx+='<xdr:twoCellAnchor editAs="twoCell">';
6695 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>';
6696 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>';
6697 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6698 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6699 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6700 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6701 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6702 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6703 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>')}});
6704 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6705 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6706 }}
6707 if(hasChart2){{
6708 var fromRow2=nr2+4,toRow2=nr2+24;
6709 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>')}});
6710 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6711 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">';
6712 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6713 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>';
6714 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>';
6715 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6716 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6717 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6718 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6719 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6720 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6721 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>')}});
6722 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6723 }}
6724 var parts=[],offsets=[],total=0;
6725 files.forEach(function(f){{
6726 offsets.push(total);
6727 var nb=s2b(f.name),crc=crc32(f.data);
6728 var h=new DataView(new ArrayBuffer(30+nb.length));
6729 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6730 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6731 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6732 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6733 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6734 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6735 total+=30+nb.length+f.data.length;
6736 }});
6737 var cdStart=total;
6738 files.forEach(function(f,fi){{
6739 var nb=s2b(f.name),crc=crc32(f.data);
6740 var cd=new DataView(new ArrayBuffer(46+nb.length));
6741 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6742 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6743 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6744 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6745 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6746 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6747 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6748 }});
6749 var cdSz=total-cdStart;
6750 var eocd=new DataView(new ArrayBuffer(22));
6751 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6752 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6753 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6754 parts.push(new Uint8Array(eocd.buffer));
6755 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6756 var out=new Uint8Array(sz);var off=0;
6757 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6758 return out.buffer;
6759 }}
6760
6761 function exportPNG(){{
6762 var svgEl=document.querySelector('#chart-wrap svg');
6763 if(!svgEl){{alert('No chart to export yet.');return;}}
6764 var svgStr=new XMLSerializer().serializeToString(svgEl);
6765 var vb=svgEl.viewBox.baseVal,scale=2;
6766 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6767 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6768 var url=URL.createObjectURL(blob);
6769 var img=new Image();
6770 img.onload=function(){{
6771 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6772 var ctx=canvas.getContext('2d');
6773 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6774 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6775 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6776 URL.revokeObjectURL(url);
6777 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6778 }};
6779 img.src=url;
6780 }}
6781
6782 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6783 var el=document.getElementById(id);
6784 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6785 }});
6786 rootSel.addEventListener('change',function(){{
6787 populateSubmodules(rootSel.value);
6788 loadAndRender();
6789 }});
6790 if(subSel)subSel.addEventListener('change',loadAndRender);
6791
6792 var xlsxBtn=document.getElementById('export-xlsx-btn');
6793 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6794 var pngBtn=document.getElementById('export-png-btn');
6795 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6796
6797 populateSubmodules(rootSel.value);
6798 loadAndRender();
6799
6800 (function randomizeWatermarks() {{
6801 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6802 if (!wms.length) return;
6803 var placed = [];
6804 function tooClose(top, left) {{
6805 for (var i = 0; i < placed.length; i++) {{
6806 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6807 if (dt < 16 && dl < 12) return true;
6808 }}
6809 return false;
6810 }}
6811 function pick(leftBand) {{
6812 for (var attempt = 0; attempt < 50; attempt++) {{
6813 var top = Math.random() * 88 + 2;
6814 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6815 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6816 }}
6817 var top = Math.random() * 88 + 2;
6818 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6819 placed.push([top, left]); return [top, left];
6820 }}
6821 var half = Math.floor(wms.length / 2);
6822 wms.forEach(function (img, i) {{
6823 var pos = pick(i < half);
6824 var size = Math.floor(Math.random() * 100 + 120);
6825 var rot = (Math.random() * 360).toFixed(1);
6826 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6827 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;
6828 }});
6829 }})();
6830 (function spawnCodeParticles() {{
6831 var container = document.getElementById('code-particles');
6832 if (!container) return;
6833 var snippets = [
6834 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6835 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6836 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6837 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6838 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6839 ];
6840 var count = 38;
6841 for (var i = 0; i < count; i++) {{
6842 (function(idx) {{
6843 var el = document.createElement('span');
6844 el.className = 'code-particle';
6845 el.textContent = snippets[idx % snippets.length];
6846 var left = Math.random() * 94 + 2;
6847 var top = Math.random() * 88 + 6;
6848 var dur = (Math.random() * 10 + 9).toFixed(1);
6849 var delay = (Math.random() * 18).toFixed(1);
6850 var rot = (Math.random() * 26 - 13).toFixed(1);
6851 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6852 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6853 container.appendChild(el);
6854 }})(i);
6855 }}
6856 }})();
6857 </script>
6858 <footer class="site-footer">
6859 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
6860 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6861 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6862 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6863 · <a href="/api-docs" rel="noopener">REST API</a>
6864 </footer>
6865</body>
6866</html>"##,
6867 );
6868
6869 Html(html).into_response()
6870}
6871
6872#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6875 use std::collections::HashMap;
6877 let mut langs: Vec<&sloc_core::LanguageSummary> = run
6878 .totals_by_language
6879 .iter()
6880 .filter(|l| l.test_count > 0)
6881 .collect();
6882 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6883 let lang_tests: Vec<serde_json::Value> = langs
6884 .iter()
6885 .map(|l| {
6886 let d = if l.code_lines > 0 {
6887 l.test_count as f64 / l.code_lines as f64 * 1000.0
6888 } else {
6889 0.0
6890 };
6891 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6892 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6893 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6894 })
6895 .collect();
6896 let has_file_cov = run.per_file_records.iter().any(|f| f.coverage.is_some());
6897 let cov_arr: Vec<serde_json::Value> = if has_file_cov {
6898 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6899 for rec in &run.per_file_records {
6900 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6901 let e = totals.entry(lang.display_name().to_string()).or_default();
6902 e.0 += u64::from(cov.lines_found);
6903 e.1 += u64::from(cov.lines_hit);
6904 }
6905 }
6906 let mut pairs: Vec<(String, f64)> = totals
6907 .into_iter()
6908 .filter(|(_, (found, _))| *found > 0)
6909 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6910 .collect();
6911 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6912 pairs
6913 .iter()
6914 .map(
6915 |(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}),
6916 )
6917 .collect()
6918 } else {
6919 vec![]
6920 };
6921 let (mut high, mut mid, mut low) = (0u64, 0u64, 0u64);
6922 for rec in &run.per_file_records {
6923 if let Some(cov) = &rec.coverage {
6924 if cov.lines_found == 0 {
6925 continue;
6926 }
6927 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6928 if pct >= 80.0 {
6929 high += 1;
6930 } else if pct >= 50.0 {
6931 mid += 1;
6932 } else {
6933 low += 1;
6934 }
6935 }
6936 }
6937 let t = &run.summary_totals;
6938 let total_tests = t.test_count;
6939 let density = if t.code_lines > 0 {
6940 total_tests as f64 / t.code_lines as f64 * 1000.0
6941 } else {
6942 0.0
6943 };
6944 let most_tested = langs.first().map_or_else(
6945 || "\u{2014}".to_string(),
6946 |l| l.language.display_name().to_string(),
6947 );
6948 let test_files: u64 = run
6949 .per_file_records
6950 .iter()
6951 .filter(|f| f.raw_line_categories.test_count > 0)
6952 .count() as u64;
6953 let cov_line = if t.coverage_lines_found > 0 {
6954 format!(
6955 "{:.1}",
6956 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6957 )
6958 } else {
6959 "0".to_string()
6960 };
6961 let cov_fn = if t.coverage_functions_found > 0 {
6962 format!(
6963 "{:.1}",
6964 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6965 )
6966 } else {
6967 "0".to_string()
6968 };
6969 let cov_branch = if t.coverage_branches_found > 0 {
6970 format!(
6971 "{:.1}",
6972 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6973 )
6974 } else {
6975 "0".to_string()
6976 };
6977 let has_cov = !cov_arr.is_empty();
6978 let mut file_cov_arr: Vec<serde_json::Value> = run
6979 .per_file_records
6980 .iter()
6981 .filter_map(|rec| {
6982 rec.coverage.as_ref().map(|cov| {
6983 let line_pct = if cov.lines_found > 0 {
6984 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6985 / 10.0
6986 } else {
6987 0.0
6988 };
6989 let fn_pct = if cov.functions_found > 0 {
6990 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6991 .round()
6992 / 10.0
6993 } else {
6994 -1.0
6995 };
6996 serde_json::json!({
6997 "rel": rec.relative_path,
6998 "lang": rec.language.map_or("?", |l| l.display_name()),
6999 "line_pct": line_pct,
7000 "fn_pct": fn_pct,
7001 "lhit": cov.lines_hit,
7002 "lfound": cov.lines_found,
7003 "fhit": cov.functions_hit,
7004 "ffound": cov.functions_found,
7005 })
7006 })
7007 })
7008 .collect();
7009 file_cov_arr.sort_by(|a, b| {
7010 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7011 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7012 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7013 });
7014 serde_json::json!({
7015 "totals": {
7016 "test_count": total_tests,
7017 "assertions": t.test_assertion_count,
7018 "suites": t.test_suite_count,
7019 "test_files": test_files,
7020 "total_files": t.files_analyzed,
7021 "density_str": format!("{density:.1}"),
7022 "most_tested": most_tested,
7023 "langs_with_tests": langs.len(),
7024 "cov_line": cov_line,
7025 "cov_fn": cov_fn,
7026 "cov_branch": cov_branch,
7027 },
7028 "lang_tests": lang_tests,
7029 "cov": cov_arr,
7030 "cov_tiers": {"high": high, "mid": mid, "low": low},
7031 "file_cov": file_cov_arr,
7032 "has_coverage": has_cov,
7033 "submodules": {},
7034 })
7035}
7036
7037#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
7039 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
7040 .language_summaries
7041 .iter()
7042 .filter(|l| l.test_count > 0)
7043 .collect();
7044 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7045 let lang_tests: Vec<serde_json::Value> = langs
7046 .iter()
7047 .map(|l| {
7048 let d = if l.code_lines > 0 {
7049 l.test_count as f64 / l.code_lines as f64 * 1000.0
7050 } else {
7051 0.0
7052 };
7053 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
7054 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
7055 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
7056 })
7057 .collect();
7058 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
7059 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
7060 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
7061 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
7062 let density = if sub.code_lines > 0 {
7063 total_tests as f64 / sub.code_lines as f64 * 1000.0
7064 } else {
7065 0.0
7066 };
7067 let most_tested = langs.first().map_or_else(
7068 || "\u{2014}".to_string(),
7069 |l| l.language.display_name().to_string(),
7070 );
7071 serde_json::json!({
7072 "totals": {
7073 "test_count": total_tests,
7074 "assertions": total_assertions,
7075 "suites": total_suites,
7076 "test_files": test_files_approx,
7077 "total_files": sub.files_analyzed,
7078 "density_str": format!("{density:.1}"),
7079 "most_tested": most_tested,
7080 "langs_with_tests": langs.len(),
7081 "cov_line": "0",
7082 "cov_fn": "0",
7083 "cov_branch": "0",
7084 },
7085 "lang_tests": lang_tests,
7086 "cov": [],
7087 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
7088 "has_coverage": false,
7089 })
7090}
7091
7092#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
7096 State(state): State<AppState>,
7098 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7099) -> Response {
7100 auto_scan_watched_dirs(&state).await;
7101 let watched_dirs_list: Vec<String> = {
7102 let wd = state.watched_dirs.lock().await;
7103 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7104 };
7105 let latest_run: Option<AnalysisRun> = {
7106 let reg = state.registry.lock().await;
7107 let json_str: Option<String> = reg
7108 .entries
7109 .first()
7110 .and_then(|e| e.json_path.as_ref())
7111 .and_then(|p| std::fs::read_to_string(p).ok());
7112 drop(reg);
7113 json_str
7114 .as_deref()
7115 .and_then(|s| serde_json::from_str(s).ok())
7116 };
7117
7118 let _lang_tests_json: String = latest_run.as_ref().map_or_else(
7120 || "[]".to_string(),
7121 |r| {
7122 let mut langs: Vec<&sloc_core::LanguageSummary> = r
7123 .totals_by_language
7124 .iter()
7125 .filter(|l| l.test_count > 0)
7126 .collect();
7127 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7128 let parts: Vec<String> = langs
7129 .iter()
7130 .map(|l| {
7131 let name = l.language.display_name().replace('"', "\\\"");
7132 let density = if l.code_lines > 0 {
7133 #[allow(clippy::cast_precision_loss)]
7135 { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
7136 } else {
7137 0.0
7138 };
7139 format!(
7140 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
7141 name = name,
7142 t = l.test_count,
7143 a = l.test_assertion_count,
7144 s = l.test_suite_count,
7145 c = l.code_lines,
7146 d = density,
7147 f = l.files,
7148 )
7149 })
7150 .collect();
7151 format!("[{}]", parts.join(","))
7152 },
7153 );
7154
7155 let cov_json: String = match &latest_run {
7157 Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7158 use std::collections::HashMap;
7159 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7160 for rec in &r.per_file_records {
7161 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7162 let e = totals.entry(lang.display_name().to_string()).or_default();
7163 e.0 += u64::from(cov.lines_found);
7164 e.1 += u64::from(cov.lines_hit);
7165 }
7166 }
7167 let mut pairs: Vec<(String, f64)> = totals
7168 .into_iter()
7169 .filter(|(_, (found, _))| *found > 0)
7170 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7171 .collect();
7172 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7173 let parts: Vec<String> = pairs
7174 .iter()
7175 .map(|(lang, pct)| {
7176 let name = lang.replace('"', "\\\"");
7177 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
7178 })
7179 .collect();
7180 format!("[{}]", parts.join(","))
7181 }
7182 _ => "[]".to_string(),
7183 };
7184
7185 let _cov_tier_json: String = match &latest_run {
7187 Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7188 let mut high = 0u64; let mut mid = 0u64; let mut low = 0u64; for rec in &r.per_file_records {
7192 if let Some(cov) = &rec.coverage {
7193 if cov.lines_found == 0 {
7194 continue;
7195 }
7196 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7197 if pct >= 80.0 {
7198 high += 1;
7199 } else if pct >= 50.0 {
7200 mid += 1;
7201 } else {
7202 low += 1;
7203 }
7204 }
7205 }
7206 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
7207 }
7208 _ => r#"{"high":0,"mid":0,"low":0}"#.to_string(),
7209 };
7210
7211 let total_tests: u64 = latest_run
7212 .as_ref()
7213 .map_or(0, |r| r.summary_totals.test_count);
7214 let total_assertions: u64 = latest_run
7215 .as_ref()
7216 .map_or(0, |r| r.summary_totals.test_assertion_count);
7217 let total_suites: u64 = latest_run
7218 .as_ref()
7219 .map_or(0, |r| r.summary_totals.test_suite_count);
7220 let total_code: u64 = latest_run
7221 .as_ref()
7222 .map_or(0, |r| r.summary_totals.code_lines);
7223 let workspace_density: f64 = if total_code > 0 {
7224 total_tests as f64 / total_code as f64 * 1000.0
7225 } else {
7226 0.0
7227 };
7228 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
7229 r.totals_by_language
7230 .iter()
7231 .filter(|l| l.test_count > 0)
7232 .count()
7233 });
7234 let most_tested: String = latest_run
7235 .as_ref()
7236 .and_then(|r| {
7237 r.totals_by_language
7238 .iter()
7239 .filter(|l| l.test_count > 0)
7240 .max_by_key(|l| l.test_count)
7241 })
7242 .map_or_else(
7243 || "\u{2014}".to_string(),
7244 |l| l.language.display_name().to_string(),
7245 );
7246 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7247 r.per_file_records
7248 .iter()
7249 .filter(|f| f.raw_line_categories.test_count > 0)
7250 .count() as u64
7251 });
7252 let total_files_analyzed: u64 = latest_run
7253 .as_ref()
7254 .map_or(0, |r| r.summary_totals.files_analyzed);
7255 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7256
7257 let cov_line_pct_str: String = latest_run
7259 .as_ref()
7260 .filter(|r| r.summary_totals.coverage_lines_found > 0)
7261 .map_or_else(
7262 || "0".to_string(),
7263 |r| {
7264 format!(
7265 "{:.1}",
7266 r.summary_totals.coverage_lines_hit as f64
7267 / r.summary_totals.coverage_lines_found as f64
7268 * 100.0
7269 )
7270 },
7271 );
7272 let cov_fn_pct_str: String = latest_run
7273 .as_ref()
7274 .filter(|r| r.summary_totals.coverage_functions_found > 0)
7275 .map_or_else(
7276 || "0".to_string(),
7277 |r| {
7278 format!(
7279 "{:.1}",
7280 r.summary_totals.coverage_functions_hit as f64
7281 / r.summary_totals.coverage_functions_found as f64
7282 * 100.0
7283 )
7284 },
7285 );
7286 let cov_branch_pct_str: String = latest_run
7287 .as_ref()
7288 .filter(|r| r.summary_totals.coverage_branches_found > 0)
7289 .map_or_else(
7290 || "0".to_string(),
7291 |r| {
7292 format!(
7293 "{:.1}",
7294 r.summary_totals.coverage_branches_hit as f64
7295 / r.summary_totals.coverage_branches_found as f64
7296 * 100.0
7297 )
7298 },
7299 );
7300
7301 let cov_no_data_notice = if has_coverage {
7302 String::new()
7303 } else {
7304 String::from(
7305 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7306<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>
7307<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7308 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7309 <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>
7310 <span style="color:var(--muted);font-size:12px;">·</span>
7311 <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>
7312 <span style="color:var(--muted);font-size:12px;">·</span>
7313 <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>
7314</div>
7315<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7316</div>"#,
7317 )
7318 };
7319
7320 let workspace_density_str = format!("{workspace_density:.1}");
7321 let nonce = &csp_nonce;
7322 let version = env!("CARGO_PKG_VERSION");
7323
7324 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7325 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7326 .to_string()
7327 } else {
7328 watched_dirs_list
7329 .iter()
7330 .fold(String::new(), |mut s, d| {
7331 use std::fmt::Write as _;
7332 let escaped =
7333 d.replace('&', "&").replace('"', """).replace('<', "<");
7334 write!(
7335 s,
7336 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>"#
7337 ).expect("write to String is infallible");
7338 s
7339 })
7340 };
7341 let watched_dirs_html = format!(
7342 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>"#
7343 );
7344
7345 let scope_data_json: String = {
7347 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7348 scope_map.insert(
7349 "__all__".to_string(),
7350 latest_run.as_ref().map_or_else(
7351 || {
7352 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7353 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7354 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7355 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7356 "has_coverage":false,"submodules":{}})
7357 },
7358 build_test_scope_entry,
7359 ),
7360 );
7361 let all_roots: Vec<String> = {
7362 let reg = state.registry.lock().await;
7363 let mut seen = std::collections::BTreeSet::new();
7364 reg.entries
7365 .iter()
7366 .flat_map(|e| e.input_roots.iter().cloned())
7367 .filter(|r| seen.insert(r.clone()))
7368 .collect()
7369 };
7370 for root in &all_roots {
7371 let run_for_root: Option<AnalysisRun> = {
7372 let reg = state.registry.lock().await;
7373 let json_str = reg
7374 .entries
7375 .iter()
7376 .find(|e| e.input_roots.iter().any(|r| r == root))
7377 .and_then(|e| e.json_path.as_ref())
7378 .and_then(|p| std::fs::read_to_string(p).ok());
7379 drop(reg);
7380 json_str
7381 .as_deref()
7382 .and_then(|s| serde_json::from_str(s).ok())
7383 };
7384 if let Some(ref run) = run_for_root {
7385 let mut root_entry = build_test_scope_entry(run);
7386 if !run.submodule_summaries.is_empty() {
7387 let subs: serde_json::Map<String, serde_json::Value> = run
7388 .submodule_summaries
7389 .iter()
7390 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
7391 .collect();
7392 root_entry["submodules"] = serde_json::Value::Object(subs);
7393 }
7394 scope_map.insert(root.clone(), root_entry);
7395 }
7396 }
7397 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7398 };
7399
7400 let html = format!(
7401 r#"<!doctype html>
7402<html lang="en">
7403<head>
7404 <meta charset="utf-8" />
7405 <meta name="viewport" content="width=device-width, initial-scale=1" />
7406 <title>OxideSLOC | Test Metrics</title>
7407 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7408 <style nonce="{nonce}">
7409 :root {{
7410 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7411 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7412 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7413 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7414 --info-bg:#eef3ff; --info-text:#4467d8;
7415 }}
7416 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7417 *{{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);}}
7418 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7419 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7420 .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;}}
7421 @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));}}}}
7422 .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);}}
7423 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7424 .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));}}
7425 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7426 .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;}}
7427 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7428 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7429 @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; }} }}
7430 .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;}}
7431 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7432 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7433 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7434 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7435 .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;}}
7436 .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;}}
7437 .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;}}
7438 .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;}}
7439 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7440 .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);}}
7441 .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;}}
7442 .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;}}
7443 .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;}}
7444 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7445 .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;}}
7446 .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);}}
7447 .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;}}
7448 .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;}}
7449 .tz-select:focus{{border-color:var(--oxide);}}
7450 .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7451 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7452 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7453 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7454 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7455 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7456 .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;}}
7457 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7458 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7459 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7460 .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;}}
7461 .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;}}
7462 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7463 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7464 .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);}}
7465 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7466 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7467 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7468 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7469 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7470 .chart-canvas-wrap{{position:relative;height:280px;}}
7471 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7472 .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;}}
7473 .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;}}
7474 .data-table tr:last-child td{{border-bottom:none;}}
7475 .data-table tbody tr:hover td{{background:var(--surface-2);}}
7476 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7477 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7478 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7479 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7480 .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;}}
7481 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7482 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7483 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7484 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7485 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7486 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7487 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7488 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7489 .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;}}
7490 .chart-select:focus{{border-color:var(--accent);}}
7491 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7492 .trend-canvas-wrap{{position:relative;height:260px;}}
7493 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7494 .site-footer a{{color:var(--muted);}}
7495 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7496 .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;}}
7497 .btn:hover{{background:var(--surface-2);}}
7498 .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;}}
7499 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7500 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7501 .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;}}
7502 .scope-sel:focus{{border-color:var(--accent);}}
7503 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7504 .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;}}
7505 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7506 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7507 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7508 .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;}}
7509 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7510 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7511 .watched-chip-rm:hover{{color:var(--oxide);}}
7512 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7513 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7514 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7515 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7516 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7517 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7518 .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;}}
7519 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7520 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7521 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7522 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7523 .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;}}
7524 .cov-file-search:focus{{border-color:var(--accent);}}
7525 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7526 .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;}}
7527 body.dark-theme .cov-file-search{{background:var(--surface);}}
7528 </style>
7529</head>
7530<body>
7531 <div class="background-watermarks" aria-hidden="true">
7532 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7533 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7534 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7535 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7536 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7537 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7538 </div>
7539 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7540 <div class="top-nav">
7541 <div class="top-nav-inner">
7542 <a class="brand" href="/">
7543 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7544 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7545 </a>
7546 <div class="nav-right">
7547 <a class="nav-pill" href="/">Home</a>
7548 <div class="nav-dropdown">
7549 <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>
7550 <div class="nav-dropdown-menu">
7551 <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>
7552 </div>
7553 </div>
7554 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7555 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7556 <div class="nav-dropdown">
7557 <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>
7558 <div class="nav-dropdown-menu">
7559 <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>
7560 </div>
7561 </div>
7562 <div class="server-status-wrap">
7563 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7564 <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>
7565 </div>
7566 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7567 <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>
7568 </button>
7569 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7570 <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>
7571 <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>
7572 </button>
7573 </div>
7574 </div>
7575 </div>
7576
7577 <div class="page">
7578 {watched_dirs_html}
7579 <div class="scope-bar">
7580 <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>
7581 <span class="scope-label">Scope</span>
7582 <div class="scope-sel-wrap">
7583 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7584 <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);">
7585 <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>
7586 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7587 </div>
7588 </div>
7589 </div>
7590 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7591 <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>
7592 <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>
7593 <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>
7594 <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>
7595 </div>
7596 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7597 <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>
7598 <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>
7599 <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>
7600 <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>
7601 </div>
7602
7603 <div class="panel">
7604 <h1>Test Metrics</h1>
7605 <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>
7606
7607 <div class="chart-row">
7608 <div class="chart-box">
7609 <div class="chart-box-title">Test Definitions by Language</div>
7610 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7611 </div>
7612 <div class="chart-box">
7613 <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7614 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7615 </div>
7616 </div>
7617
7618 <div class="section-header">Language Breakdown</div>
7619 {cov_no_data_notice}
7620 <div style="overflow-x:auto;">
7621 <table class="data-table" id="lang-table">
7622 <thead><tr>
7623 <th>Language</th>
7624 <th class="num">Test Fns</th>
7625 <th class="num">Assertions</th>
7626 <th class="num">Suites</th>
7627 <th class="num">Code Lines</th>
7628 <th class="num">Files</th>
7629 <th class="num">Density / 1K</th>
7630 <th>Relative Density</th>
7631 </tr></thead>
7632 <tbody id="lang-tbody"></tbody>
7633 </table>
7634 </div>
7635 </div>
7636
7637 <div class="panel" id="cov-panel" style="display:none;">
7638 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7639 <div class="cov-gauge-row" id="cov-gauges">
7640 <div class="cov-gauge-card">
7641 <div class="cov-gauge-label">Line Coverage</div>
7642 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7643 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7644 <div class="cov-gauge-sub">Lines hit / instrumented</div>
7645 </div>
7646 <div class="cov-gauge-card">
7647 <div class="cov-gauge-label">Function Coverage</div>
7648 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7649 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7650 <div class="cov-gauge-sub">Functions hit / found</div>
7651 </div>
7652 <div class="cov-gauge-card">
7653 <div class="cov-gauge-label">Branch Coverage</div>
7654 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7655 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7656 <div class="cov-gauge-sub">Branches hit / found</div>
7657 </div>
7658 </div>
7659 <div class="chart-row">
7660 <div class="chart-box">
7661 <div class="chart-box-title">Line Coverage % by Language</div>
7662 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7663 </div>
7664 <div class="chart-box">
7665 <div class="chart-box-title">Coverage Tier Distribution</div>
7666 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7667 </div>
7668 </div>
7669
7670 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7671 <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>
7672 <div class="cov-file-toolbar">
7673 <div class="cov-filter-tabs" id="cov-filter-tabs">
7674 <button class="cov-tab active" data-tier="all">All</button>
7675 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7676 <button class="cov-tab" data-tier="low">Low (<50%)</button>
7677 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7678 <button class="cov-tab" data-tier="high">High (≥80%)</button>
7679 </div>
7680 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7681 </div>
7682 <div style="overflow-x:auto;">
7683 <table class="data-table" id="cov-file-table">
7684 <thead><tr>
7685 <th>File</th>
7686 <th>Lang</th>
7687 <th class="num">Line %</th>
7688 <th class="num">Lines Hit / Found</th>
7689 <th class="num">Fn %</th>
7690 <th class="num">Fns Hit / Found</th>
7691 </tr></thead>
7692 <tbody id="cov-file-tbody"></tbody>
7693 </table>
7694 </div>
7695 <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>
7696 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7697 </div>
7698
7699 <div class="panel">
7700 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7701 <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7702 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7703 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7704 </div>
7705 </div>
7706
7707 <footer class="site-footer">
7708 oxide-sloc v{version} — local code analysis - metrics, history and reports ·
7709 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7710 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7711 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7712 · <a href="/api-docs" rel="noopener">REST API</a>
7713 </footer>
7714
7715 <script nonce="{nonce}">
7716 (function() {{
7717 // Theme
7718 var b = document.body;
7719 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7720 var tgl = document.getElementById('theme-toggle');
7721 if (tgl) tgl.addEventListener('click', function() {{
7722 var d = b.classList.toggle('dark-theme');
7723 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7724 }});
7725
7726 // Watermarks
7727 (function() {{
7728 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7729 if (!wms.length) return;
7730 var placed = [];
7731 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;}}
7732 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];}}
7733 var half=Math.floor(wms.length/2);
7734 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;}});
7735 }})();
7736
7737 // Code particles
7738 (function() {{
7739 var container = document.getElementById('code-particles');
7740 if (!container) return;
7741 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7742 for (var i = 0; i < 36; i++) {{
7743 (function(idx) {{
7744 var el = document.createElement('span');
7745 el.className = 'code-particle';
7746 el.textContent = snippets[idx % snippets.length];
7747 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7748 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7749 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7750 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';
7751 container.appendChild(el);
7752 }})(i);
7753 }}
7754 }})();
7755
7756 // Settings modal
7757 (function() {{
7758 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'}}];
7759 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);}});}}
7760 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7761 var btn=document.getElementById('settings-btn');if(!btn)return;
7762 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7763 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>';
7764 document.body.appendChild(m);
7765 var g=document.getElementById('scheme-grid');
7766 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);}});
7767 var cl=document.getElementById('settings-close');
7768 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');}});
7769 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7770 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7771 }})();
7772
7773 // Watched folder picker
7774 (function() {{
7775 var btn = document.getElementById('add-watched-btn');
7776 if (!btn) return;
7777 btn.addEventListener('click', function() {{
7778 fetch('/pick-directory?kind=reports')
7779 .then(function(r) {{ return r.json(); }})
7780 .then(function(data) {{
7781 if (!data.cancelled && data.selected_path) {{
7782 var form = document.createElement('form');
7783 form.method = 'POST';
7784 form.action = '/watched-dirs/add';
7785 var ri = document.createElement('input');
7786 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7787 var fi = document.createElement('input');
7788 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7789 form.appendChild(ri); form.appendChild(fi);
7790 document.body.appendChild(form);
7791 form.submit();
7792 }}
7793 }})
7794 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7795 }});
7796 }})();
7797 }})();
7798 </script>
7799
7800 <script src="/static/chart.js" nonce="{nonce}"></script>
7801 <script nonce="{nonce}">
7802 (function() {{
7803 var SCOPE_DATA = {scope_data_json};
7804 var currentRoot = '__all__';
7805 var currentSub = '';
7806 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7807 var ALL_CHARTS = [];
7808
7809 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();}}
7810 function fmtFull(n){{return Number(n).toLocaleString();}}
7811 function isDark(){{return document.body.classList.contains('dark-theme');}}
7812 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7813 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7814 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7815
7816 function getDataset() {{
7817 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7818 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7819 return r;
7820 }}
7821 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7822
7823 function renderTestCharts(D) {{
7824 testsChart = destroyChart(testsChart);
7825 densityChart = destroyChart(densityChart);
7826 if (!D || !D.length) return;
7827 var top15 = D.slice(0, 15);
7828 var canvas1 = document.getElementById('canvas-tests');
7829 if (canvas1) {{
7830 testsChart = new Chart(canvas1, {{
7831 type: 'bar',
7832 data: {{
7833 labels: top15.map(function(d){{ return d.lang; }}),
7834 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7835 }},
7836 options: {{
7837 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7838 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7839 scales: {{
7840 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7841 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7842 }}
7843 }}
7844 }});
7845 ALL_CHARTS.push(testsChart);
7846 }}
7847 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7848 var canvas2 = document.getElementById('canvas-density');
7849 if (canvas2) {{
7850 densityChart = new Chart(canvas2, {{
7851 type: 'bar',
7852 data: {{
7853 labels: topD.map(function(d){{ return d.lang; }}),
7854 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 }}]
7855 }},
7856 options: {{
7857 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7858 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7859 scales: {{
7860 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7861 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7862 }}
7863 }}
7864 }});
7865 ALL_CHARTS.push(densityChart);
7866 }}
7867 }}
7868
7869 function renderCovCharts(covD, tiers) {{
7870 covChart = destroyChart(covChart);
7871 tierChart = destroyChart(tierChart);
7872 var covCanvas = document.getElementById('canvas-cov');
7873 if (covCanvas && covD && covD.length) {{
7874 covChart = new Chart(covCanvas, {{
7875 type: 'bar',
7876 data: {{
7877 labels: covD.map(function(d){{ return d.lang; }}),
7878 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 }}]
7879 }},
7880 options: {{
7881 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7882 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7883 scales: {{
7884 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7885 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7886 }}
7887 }}
7888 }});
7889 ALL_CHARTS.push(covChart);
7890 }}
7891 var tierCanvas = document.getElementById('canvas-cov-tiers');
7892 if (tierCanvas && tiers) {{
7893 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7894 tierChart = new Chart(tierCanvas, {{
7895 type: 'doughnut',
7896 data: {{
7897 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7898 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7899 }},
7900 options: {{
7901 responsive: true, maintainAspectRatio: false, cutout: '62%',
7902 plugins: {{
7903 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7904 tooltip: {{ callbacks: {{ label: function(ctx) {{
7905 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7906 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7907 }} }} }}
7908 }}
7909 }}
7910 }});
7911 ALL_CHARTS.push(tierChart);
7912 }}
7913 }}
7914
7915 function buildLangTable(D) {{
7916 var tbody = document.getElementById('lang-tbody');
7917 if (!tbody) return;
7918 if (!D || !D.length) {{
7919 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>';
7920 return;
7921 }}
7922 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7923 tbody.innerHTML = D.map(function(d) {{
7924 var barW = Math.round(d.density / maxDensity * 120);
7925 return '<tr>' +
7926 '<td><strong>' + d.lang + '</strong></td>' +
7927 '<td class="num">' + fmt(d.tests) + '</td>' +
7928 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7929 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7930 '<td class="num">' + fmt(d.code) + '</td>' +
7931 '<td class="num">' + fmt(d.files) + '</td>' +
7932 '<td class="num">' + d.density.toFixed(2) + '</td>' +
7933 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7934 '</tr>';
7935 }}).join('');
7936 }}
7937
7938 var covFileData = [];
7939 var covFileTier = 'all';
7940 var covFileSearch = '';
7941
7942 function pctBadge(pct) {{
7943 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7944 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7945 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7946 }}
7947
7948 function buildCovFileTable() {{
7949 var tbody = document.getElementById('cov-file-tbody');
7950 var empty = document.getElementById('cov-file-empty');
7951 var count = document.getElementById('cov-file-count');
7952 if (!tbody) return;
7953 var srch = covFileSearch.toLowerCase();
7954 var filtered = covFileData.filter(function(f) {{
7955 if (covFileTier === 'zero' && f.line_pct > 0) return false;
7956 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7957 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7958 if (covFileTier === 'high' && f.line_pct < 80) return false;
7959 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7960 return true;
7961 }});
7962 if (!filtered.length) {{
7963 tbody.innerHTML = '';
7964 if (empty) empty.style.display = '';
7965 if (count) count.textContent = '';
7966 return;
7967 }}
7968 if (empty) empty.style.display = 'none';
7969 var shown = Math.min(filtered.length, 500);
7970 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7971 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7972 var fnCol = f.fn_pct < 0
7973 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7974 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7975 return '<tr>' +
7976 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
7977 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7978 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7979 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7980 fnCol +
7981 '</tr>';
7982 }}).join('');
7983 }}
7984
7985 (function() {{
7986 var tabs = document.getElementById('cov-filter-tabs');
7987 if (tabs) {{
7988 tabs.addEventListener('click', function(e) {{
7989 var btn = e.target.closest('.cov-tab');
7990 if (!btn) return;
7991 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7992 btn.classList.add('active');
7993 covFileTier = btn.getAttribute('data-tier');
7994 buildCovFileTable();
7995 }});
7996 }}
7997 var srch = document.getElementById('cov-file-search');
7998 if (srch) {{
7999 srch.addEventListener('input', function() {{
8000 covFileSearch = this.value;
8001 buildCovFileTable();
8002 }});
8003 }}
8004 }})();
8005
8006 function updateCovGauges(t) {{
8007 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
8008 var el;
8009 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
8010 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
8011 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
8012 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
8013 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
8014 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
8015 }}
8016
8017 function applyScope() {{
8018 var d = getDataset();
8019 var t = d.totals;
8020 var el;
8021 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
8022 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
8023 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
8024 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
8025 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
8026 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
8027 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
8028 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
8029 renderTestCharts(d.lang_tests);
8030 buildLangTable(d.lang_tests);
8031 var covPanel = document.getElementById('cov-panel');
8032 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
8033 if (d.has_coverage) {{
8034 renderCovCharts(d.cov, d.cov_tiers);
8035 updateCovGauges(t);
8036 covFileData = d.file_cov || [];
8037 covFileTier = 'all';
8038 covFileSearch = '';
8039 var tabs = document.getElementById('cov-filter-tabs');
8040 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
8041 var srch = document.getElementById('cov-file-search');
8042 if (srch) srch.value = '';
8043 buildCovFileTable();
8044 }}
8045 loadTrend();
8046 }}
8047
8048 // Populate scope-root-sel from SCOPE_DATA keys
8049 (function() {{
8050 var sel = document.getElementById('scope-root-sel');
8051 if (!sel) return;
8052 Object.keys(SCOPE_DATA).forEach(function(k) {{
8053 if (k === '__all__') return;
8054 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
8055 }});
8056 }})();
8057
8058 document.getElementById('scope-root-sel').addEventListener('change', function() {{
8059 currentRoot = this.value;
8060 currentSub = '';
8061 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8062 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
8063 var subWrap = document.getElementById('scope-sub-wrap');
8064 var subSel = document.getElementById('scope-sub-sel');
8065 subSel.innerHTML = '<option value="">Entire project</option>';
8066 if (subNames.length) {{
8067 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
8068 subWrap.style.display = 'flex';
8069 }} else {{
8070 subWrap.style.display = 'none';
8071 }}
8072 applyScope();
8073 }});
8074
8075 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
8076 currentSub = this.value;
8077 applyScope();
8078 }});
8079
8080 function buildTrend(data) {{
8081 var trendCanvas = document.getElementById('canvas-trend');
8082 var trendEmpty = document.getElementById('trend-empty');
8083 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
8084 pts = pts.slice().reverse();
8085 if (!pts.length) {{
8086 if (trendCanvas) trendCanvas.style.display = 'none';
8087 if (trendEmpty) trendEmpty.style.display = '';
8088 return;
8089 }}
8090 if (trendCanvas) trendCanvas.style.display = '';
8091 if (trendEmpty) trendEmpty.style.display = 'none';
8092 trendChart = destroyChart(trendChart);
8093 if (!trendCanvas) return;
8094 trendChart = new Chart(trendCanvas, {{
8095 type: 'line',
8096 data: {{
8097 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
8098 datasets: [{{
8099 label: 'Test Definitions',
8100 data: pts.map(function(d){{ return d.test_count; }}),
8101 borderColor: '#C45C10',
8102 backgroundColor: 'rgba(196,92,16,0.10)',
8103 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
8104 pointRadius: 5, fill: true, tension: 0.3
8105 }}]
8106 }},
8107 options: {{
8108 responsive: true, maintainAspectRatio: false,
8109 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
8110 scales: {{
8111 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
8112 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
8113 }}
8114 }}
8115 }});
8116 ALL_CHARTS.push(trendChart);
8117 }}
8118
8119 function loadTrend() {{
8120 var url = '/api/metrics/history?limit=100';
8121 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
8122 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
8123 buildTrend(data);
8124 }}).catch(function(){{
8125 var trendEmpty = document.getElementById('trend-empty');
8126 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
8127 }});
8128 }}
8129
8130 // Re-render charts on theme toggle
8131 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
8132 setTimeout(function() {{
8133 ALL_CHARTS.forEach(function(c) {{
8134 if (c && c.options && c.options.scales) {{
8135 Object.values(c.options.scales).forEach(function(ax) {{
8136 if (ax.grid) ax.grid.color = clr();
8137 if (ax.ticks) ax.ticks.color = txtClr();
8138 }});
8139 c.update();
8140 }}
8141 }});
8142 }}, 80);
8143 }});
8144
8145 applyScope();
8146 }})();
8147 </script>
8148</body>
8149</html>"#,
8150 );
8151 Html(html).into_response()
8152}
8153
8154#[derive(Deserialize)]
8161struct EmbedQuery {
8162 run_id: Option<String>,
8163 theme: Option<String>,
8164}
8165
8166async fn embed_handler(
8167 State(state): State<AppState>,
8168 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8169 Query(query): Query<EmbedQuery>,
8170) -> Response {
8171 let entry = {
8172 let reg = state.registry.lock().await;
8173 query.run_id.as_ref().map_or_else(
8174 || reg.entries.first().cloned(),
8175 |id| reg.find_by_run_id(id).cloned(),
8176 )
8177 };
8178
8179 let Some(entry) = entry else {
8180 return Html(
8181 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
8182 .to_string(),
8183 )
8184 .into_response();
8185 };
8186
8187 let dark = query.theme.as_deref() == Some("dark");
8188 let languages: Vec<(String, u64, u64)> = entry
8189 .json_path
8190 .as_ref()
8191 .and_then(|p| read_json(p).ok())
8192 .map(|run| {
8193 run.totals_by_language
8194 .iter()
8195 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
8196 .collect()
8197 })
8198 .unwrap_or_default();
8199
8200 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
8201}
8202
8203fn render_embed_widget(
8204 entry: &RegistryEntry,
8205 languages: &[(String, u64, u64)],
8206 dark: bool,
8207 csp_nonce: &str,
8208) -> String {
8209 let s = &entry.summary;
8210 let total = s.code_lines + s.comment_lines + s.blank_lines;
8211 let code_pct = s
8212 .code_lines
8213 .checked_mul(100)
8214 .and_then(|n| n.checked_div(total))
8215 .unwrap_or(0);
8216
8217 let (bg, fg, surface, muted, border) = if dark {
8218 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
8219 } else {
8220 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
8221 };
8222
8223 let mut lang_rows = String::new();
8224 for (name, files, code) in languages {
8225 write!(
8226 lang_rows,
8227 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
8228 escape_html(name),
8229 format_number(*files),
8230 format_number(*code),
8231 )
8232 .ok();
8233 }
8234
8235 let lang_table = if lang_rows.is_empty() {
8236 String::new()
8237 } else {
8238 format!(
8239 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
8240 )
8241 };
8242
8243 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8244 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8245 let project_esc = escape_html(&entry.project_label);
8246 let code_lines = format_number(s.code_lines);
8247 let comment_lines = format_number(s.comment_lines);
8248 let files = format_number(s.files_analyzed);
8249 let code_raw = s.code_lines;
8250 let comment_raw = s.comment_lines;
8251 let blank_raw = s.blank_lines;
8252
8253 format!(
8254 r#"<!doctype html>
8255<html lang="en">
8256<head>
8257 <meta charset="utf-8">
8258 <meta name="viewport" content="width=device-width,initial-scale=1">
8259 <title>OxideSLOC — {project_esc}</title>
8260 <script src="/static/chart.js"></script>
8261 <style nonce="{csp_nonce}">
8262 *{{box-sizing:border-box;margin:0;padding:0}}
8263 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8264 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8265 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8266 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8267 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8268 .card .v{{font-size:18px;font-weight:700}}
8269 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8270 .row{{display:flex;gap:12px;align-items:flex-start}}
8271 .pie{{width:120px;height:120px;flex-shrink:0}}
8272 .lt{{border-collapse:collapse;width:100%;flex:1}}
8273 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8274 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8275 .n{{text-align:right}}
8276 .footer{{margin-top:10px;color:{muted};font-size:10px}}
8277 </style>
8278</head>
8279<body>
8280 <h2>{project_esc}</h2>
8281 <div class="sub">{timestamp} · run {run_short}</div>
8282 <div class="cards">
8283 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8284 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8285 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8286 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8287 </div>
8288 <div class="row">
8289 <canvas class="pie" id="c"></canvas>
8290 {lang_table}
8291 </div>
8292 <div class="footer">oxide-sloc</div>
8293 <script nonce="{csp_nonce}">
8294 new Chart(document.getElementById('c'),{{
8295 type:'doughnut',
8296 data:{{
8297 labels:['Code','Comments','Blank'],
8298 datasets:[{{
8299 data:[{code_raw},{comment_raw},{blank_raw}],
8300 backgroundColor:['#4a78ee','#b35428','#aaa'],
8301 borderWidth:0
8302 }}]
8303 }},
8304 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8305 }});
8306 </script>
8307</body>
8308</html>"#
8309 )
8310}
8311
8312#[allow(clippy::too_many_arguments)]
8313fn persist_run_artifacts(
8314 run: &sloc_core::AnalysisRun,
8315 report_html: &str,
8316 run_dir: &Path,
8317 generate_json: bool,
8318 generate_html: bool,
8319 generate_pdf: bool,
8320 report_title: &str,
8321 file_stem: &str,
8322 result_context: RunResultContext,
8323) -> Result<(RunArtifacts, PendingPdf)> {
8324 fs::create_dir_all(run_dir)
8325 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8326
8327 let mut html_path = None;
8328 let mut pdf_path = None;
8329 let mut json_path = None;
8330 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8331
8332 if generate_html {
8333 let path = run_dir.join(format!("report_{file_stem}.html"));
8334 fs::write(&path, report_html)
8335 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8336 html_path = Some(path);
8337 }
8338
8339 if generate_json {
8340 let path = run_dir.join(format!("result_{file_stem}.json"));
8341 let json = serde_json::to_string_pretty(run)
8342 .context("failed to serialize analysis run to JSON")?;
8343 fs::write(&path, json)
8344 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8345 json_path = Some(path);
8346 }
8347
8348 if generate_pdf {
8349 let source_html_path = if let Some(existing) = html_path.as_ref() {
8350 existing.clone()
8351 } else {
8352 let temp_html = run_dir.join("_report_rendered.html");
8353 fs::write(&temp_html, report_html).with_context(|| {
8354 format!(
8355 "failed to write temporary HTML report to {}",
8356 temp_html.display()
8357 )
8358 })?;
8359 temp_html
8360 };
8361
8362 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8363 let cleanup_src = !generate_html;
8364 pdf_path = Some(pdf_dest.clone());
8365 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8366 }
8367
8368 let csv_path = {
8370 let path = run_dir.join(format!("report_{file_stem}.csv"));
8371 if let Err(e) = sloc_report::write_csv(run, &path) {
8372 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
8373 None
8374 } else {
8375 Some(path)
8376 }
8377 };
8378
8379 let xlsx_path = {
8380 let path = run_dir.join(format!("report_{file_stem}.xlsx"));
8381 if let Err(e) = sloc_report::write_xlsx(run, &path) {
8382 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
8383 None
8384 } else {
8385 Some(path)
8386 }
8387 };
8388
8389 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8390
8391 Ok((
8392 RunArtifacts {
8393 output_dir: run_dir.to_path_buf(),
8394 html_path,
8395 pdf_path,
8396 json_path,
8397 csv_path,
8398 xlsx_path,
8399 scan_config_path,
8400 report_title: report_title.to_string(),
8401 result_context,
8402 },
8403 pending_pdf,
8404 ))
8405}
8406
8407fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8410 let exact = dir.join("scan-config.json");
8411 if exact.exists() {
8412 return Some(exact);
8413 }
8414 fs::read_dir(dir).ok().and_then(|entries| {
8415 entries
8416 .filter_map(std::result::Result::ok)
8417 .find(|e| {
8418 let name = e.file_name();
8419 let name = name.to_string_lossy();
8420 name.starts_with("scan-config") && name.ends_with(".json")
8421 })
8422 .map(|e| e.path())
8423 })
8424}
8425
8426async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8429 let toml_str = match toml::to_string_pretty(&state.base_config) {
8430 Ok(s) => s,
8431 Err(e) => {
8432 return (
8433 StatusCode::INTERNAL_SERVER_ERROR,
8434 format!("serialization error: {e}"),
8435 )
8436 .into_response();
8437 }
8438 };
8439 (
8440 [
8441 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8442 (
8443 header::CONTENT_DISPOSITION,
8444 "attachment; filename=\".oxide-sloc.toml\"",
8445 ),
8446 ],
8447 toml_str,
8448 )
8449 .into_response()
8450}
8451
8452#[derive(Deserialize)]
8453struct ImportConfigBody {
8454 toml: String,
8455}
8456
8457async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8458 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8459 Ok(config) => {
8460 if let Err(e) = config.validate() {
8461 return (
8462 StatusCode::UNPROCESSABLE_ENTITY,
8463 Json(serde_json::json!({ "error": e.to_string() })),
8464 )
8465 .into_response();
8466 }
8467 Json(serde_json::json!({ "ok": true, "config": config })).into_response()
8468 }
8469 Err(e) => (
8470 StatusCode::BAD_REQUEST,
8471 Json(serde_json::json!({ "error": format!("TOML parse error: {e}") })),
8472 )
8473 .into_response(),
8474 }
8475}
8476
8477async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8480 let store = state.scan_profiles.lock().await;
8481 Json(serde_json::json!({ "profiles": store.profiles }))
8482}
8483
8484#[derive(Deserialize)]
8485struct SaveScanProfileBody {
8486 name: String,
8487 params: serde_json::Value,
8488}
8489
8490async fn api_save_scan_profile(
8491 State(state): State<AppState>,
8492 Json(body): Json<SaveScanProfileBody>,
8493) -> impl IntoResponse {
8494 if body.name.trim().is_empty() {
8495 return (
8496 StatusCode::BAD_REQUEST,
8497 Json(serde_json::json!({ "error": "name must not be empty" })),
8498 )
8499 .into_response();
8500 }
8501
8502 let id = uuid::Uuid::new_v4().to_string();
8503 let profile = ScanProfile {
8504 id: id.clone(),
8505 name: body.name.trim().to_string(),
8506 created_at: chrono::Utc::now().to_rfc3339(),
8507 params: body.params,
8508 };
8509
8510 let mut store = state.scan_profiles.lock().await;
8511 store.profiles.push(profile);
8512 if let Err(e) = store.save(&state.scan_profiles_path) {
8513 tracing::warn!("failed to persist scan profiles: {e}");
8514 }
8515 drop(store);
8516
8517 (
8518 StatusCode::CREATED,
8519 Json(serde_json::json!({ "ok": true, "id": id })),
8520 )
8521 .into_response()
8522}
8523
8524async fn api_delete_scan_profile(
8525 State(state): State<AppState>,
8526 AxumPath(id): AxumPath<String>,
8527) -> impl IntoResponse {
8528 let mut store = state.scan_profiles.lock().await;
8529 let before = store.profiles.len();
8530 store.profiles.retain(|p| p.id != id);
8531 if store.profiles.len() == before {
8532 drop(store);
8533 return (
8534 StatusCode::NOT_FOUND,
8535 Json(serde_json::json!({ "error": "profile not found" })),
8536 )
8537 .into_response();
8538 }
8539 if let Err(e) = store.save(&state.scan_profiles_path) {
8540 tracing::warn!("failed to persist scan profiles: {e}");
8541 }
8542 drop(store);
8543 Json(serde_json::json!({ "ok": true })).into_response()
8544}
8545
8546fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8547 let value = raw.unwrap_or("out/web").trim();
8548 let path = if value.is_empty() {
8549 PathBuf::from("out/web")
8550 } else {
8551 PathBuf::from(value)
8552 };
8553
8554 if path.is_absolute() {
8555 path
8556 } else {
8557 workspace_root().join(path)
8558 }
8559}
8560
8561fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8563 std::env::var("SLOC_GIT_CLONES_DIR")
8564 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8565}
8566
8567pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8570 let safe: String = repo_url
8571 .chars()
8572 .map(|c| {
8573 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8574 c
8575 } else {
8576 '_'
8577 }
8578 })
8579 .take(80)
8580 .collect();
8581 clones_dir.join(safe)
8582}
8583
8584pub(crate) fn scan_path_to_artifacts(
8587 scan_path: &Path,
8588 base_config: &AppConfig,
8589 label: &str,
8590) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8591 let mut config = base_config.clone();
8592 config.discovery.root_paths = vec![scan_path.to_path_buf()];
8593 label.clone_into(&mut config.reporting.report_title);
8594 let run = analyze(&config, "git", None)?;
8595 let html = render_html(&run)?;
8596 let run_id = run.tool.run_id.clone();
8597 let project_label = sanitize_project_label(label);
8598 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8599 let file_stem = {
8600 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8601 if commit.is_empty() {
8602 project_label
8603 } else {
8604 format!("{project_label}_{commit}")
8605 }
8606 };
8607 let (artifacts, _pending_pdf) = persist_run_artifacts(
8608 &run,
8609 &html,
8610 &output_dir,
8611 true,
8612 true,
8613 false,
8614 label,
8615 &file_stem,
8616 RunResultContext::default(),
8617 )?;
8618 Ok((run_id, artifacts, run))
8619}
8620
8621async fn restart_poll_schedules(state: &AppState) {
8623 let store = state.schedules.lock().await;
8624 let poll_schedules: Vec<_> = store
8625 .schedules
8626 .iter()
8627 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8628 .cloned()
8629 .collect();
8630 drop(store);
8631 for schedule in poll_schedules {
8632 let interval = schedule.interval_secs.unwrap_or(300);
8633 let st = state.clone();
8634 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8635 }
8636}
8637
8638fn split_patterns(raw: Option<&str>) -> Vec<String> {
8639 raw.unwrap_or("")
8640 .lines()
8641 .flat_map(|line| line.split(','))
8642 .map(str::trim)
8643 .filter(|part| !part.is_empty())
8644 .map(ToOwned::to_owned)
8645 .collect()
8646}
8647
8648fn build_sub_run(
8649 parent: &AnalysisRun,
8650 sub: &sloc_core::SubmoduleSummary,
8651 parent_path: &str,
8652) -> AnalysisRun {
8653 let sub_files: Vec<_> = parent
8654 .per_file_records
8655 .iter()
8656 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8657 .cloned()
8658 .collect();
8659 let mut config = parent.effective_configuration.clone();
8660 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8661 AnalysisRun {
8662 tool: parent.tool.clone(),
8663 environment: parent.environment.clone(),
8664 effective_configuration: config,
8665 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8666 summary_totals: SummaryTotals {
8667 files_considered: sub.files_analyzed,
8668 files_analyzed: sub.files_analyzed,
8669 files_skipped: 0,
8670 total_physical_lines: sub.total_physical_lines,
8671 code_lines: sub.code_lines,
8672 comment_lines: sub.comment_lines,
8673 blank_lines: sub.blank_lines,
8674 mixed_lines_separate: 0,
8675 functions: 0,
8676 classes: 0,
8677 variables: 0,
8678 imports: 0,
8679 test_count: 0,
8680 test_assertion_count: 0,
8681 test_suite_count: 0,
8682 coverage_lines_found: 0,
8683 coverage_lines_hit: 0,
8684 coverage_functions_found: 0,
8685 coverage_functions_hit: 0,
8686 coverage_branches_found: 0,
8687 coverage_branches_hit: 0,
8688 },
8689 totals_by_language: sub.language_summaries.clone(),
8690 per_file_records: sub_files,
8691 skipped_file_records: vec![],
8692 warnings: vec![],
8693 submodule_summaries: vec![],
8694 git_commit_short: parent.git_commit_short.clone(),
8695 git_commit_long: parent.git_commit_long.clone(),
8696 git_branch: parent.git_branch.clone(),
8697 git_commit_author: parent.git_commit_author.clone(),
8698 git_commit_date: parent.git_commit_date.clone(),
8699 git_tags: parent.git_tags.clone(),
8700 git_nearest_tag: parent.git_nearest_tag.clone(),
8701 }
8702}
8703
8704pub(crate) fn sanitize_project_label(raw: &str) -> String {
8705 let candidate = Path::new(raw)
8706 .file_name()
8707 .and_then(|name| name.to_str())
8708 .unwrap_or("project");
8709
8710 let mut value = String::with_capacity(candidate.len());
8711 for ch in candidate.chars() {
8712 if ch.is_ascii_alphanumeric() {
8713 value.push(ch.to_ascii_lowercase());
8714 } else {
8715 value.push('-');
8716 }
8717 }
8718
8719 let compact = value.trim_matches('-').to_string();
8720 if compact.is_empty() {
8721 "project".to_string()
8722 } else {
8723 compact
8724 }
8725}
8726
8727fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8730 let s = path.to_string_lossy();
8731 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8732 return PathBuf::from(format!(r"\\{rest}"));
8733 }
8734 if let Some(rest) = s.strip_prefix(r"\\?\") {
8735 return PathBuf::from(rest);
8736 }
8737 path
8738}
8739
8740fn display_path(path: &Path) -> String {
8741 let s = path.to_string_lossy();
8742 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8747 return format!(r"\\{rest}");
8748 }
8749 if let Some(rest) = s.strip_prefix(r"\\?\") {
8750 return rest.to_owned();
8751 }
8752 s.into_owned()
8753}
8754
8755fn sanitize_path_str(s: &str) -> String {
8756 if let Some(rest) = s.strip_prefix("//?/UNC/") {
8760 return format!("//{rest}");
8761 }
8762 if let Some(rest) = s.strip_prefix("//?/") {
8763 return rest.to_owned();
8764 }
8765 display_path(Path::new(s))
8766}
8767
8768fn workspace_root() -> PathBuf {
8769 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8771 let p = PathBuf::from(root);
8772 if p.is_dir() {
8773 return p;
8774 }
8775 }
8776
8777 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8780}
8781
8782fn make_git_label(repo: &str, ref_name: &str) -> String {
8784 if repo.is_empty() || ref_name.is_empty() {
8785 return String::new();
8786 }
8787 let base = repo
8788 .trim_end_matches('/')
8789 .trim_end_matches(".git")
8790 .rsplit('/')
8791 .next()
8792 .unwrap_or("repo");
8793 let ref_safe: String = ref_name
8794 .chars()
8795 .map(|c| {
8796 if c.is_alphanumeric() || c == '-' || c == '.' {
8797 c
8798 } else {
8799 '_'
8800 }
8801 })
8802 .collect();
8803 format!("{base}_at_{ref_safe}_sloc")
8804}
8805
8806fn desktop_dir() -> PathBuf {
8808 if let Ok(profile) = std::env::var("USERPROFILE") {
8809 let p = PathBuf::from(profile).join("Desktop");
8810 if p.exists() {
8811 return p;
8812 }
8813 }
8814 if let Ok(home) = std::env::var("HOME") {
8815 let p = PathBuf::from(home).join("Desktop");
8816 if p.exists() {
8817 return p;
8818 }
8819 }
8820 workspace_root().join("out").join("web")
8821}
8822
8823fn resolve_input_path(raw: &str) -> PathBuf {
8824 let trimmed = raw.trim();
8825 if trimmed.is_empty() {
8826 return workspace_root().join("samples").join("basic");
8827 }
8828
8829 let candidate = PathBuf::from(trimmed);
8830 let resolved = if candidate.is_absolute() {
8831 candidate
8832 } else {
8833 let rooted = workspace_root().join(&candidate);
8834 if rooted.exists() {
8835 rooted
8836 } else {
8837 workspace_root().join(candidate)
8838 }
8839 };
8840
8841 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8844 PathBuf::from(display_path(&canonical))
8845}
8846
8847fn dir_size_bytes(path: &Path) -> u64 {
8848 let mut total = 0u64;
8849 if let Ok(rd) = fs::read_dir(path) {
8850 for entry in rd.filter_map(Result::ok) {
8851 let p = entry.path();
8852 if p.is_file() {
8853 if let Ok(meta) = p.metadata() {
8854 total += meta.len();
8855 }
8856 } else if p.is_dir() {
8857 total += dir_size_bytes(&p);
8858 }
8859 }
8860 }
8861 total
8862}
8863
8864#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
8866 if bytes >= 1_073_741_824 {
8867 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8868 } else if bytes >= 1_048_576 {
8869 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8870 } else if bytes >= 1_024 {
8871 format!("{:.0} KB", bytes as f64 / 1_024.0)
8872 } else {
8873 format!("{bytes} B")
8874 }
8875}
8876
8877#[allow(clippy::too_many_lines)]
8878fn build_preview_html(
8879 root: &Path,
8881 include_patterns: &[String],
8882 exclude_patterns: &[String],
8883) -> Result<String> {
8884 if !root.exists() {
8885 return Ok(format!(
8886 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8887 escape_html(&display_path(root))
8888 ));
8889 }
8890
8891 let _selected = display_path(root);
8892 let mut stats = PreviewStats::default();
8893 let mut rows = Vec::new();
8894 let mut languages = Vec::new();
8895 let mut budget = PreviewBudget {
8896 shown: 0,
8897 max_entries: 600,
8898 max_depth: 9,
8899 };
8900 let mut next_row_id = 1usize;
8901
8902 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8903 || root.to_string_lossy().into_owned(),
8904 std::string::ToString::to_string,
8905 );
8906 let root_modified = root
8907 .metadata()
8908 .ok()
8909 .and_then(|meta| meta.modified().ok())
8910 .map_or_else(|| "-".to_string(), format_system_time);
8911
8912 rows.push(PreviewRow {
8913 row_id: 0,
8914 parent_row_id: None,
8915 depth: 0,
8916 name: format!("{root_name}/"),
8917 kind: PreviewKind::Dir,
8918 is_dir: true,
8919 language: None,
8920 modified: root_modified,
8921 type_label: "Directory".to_string(),
8922 });
8923 collect_preview_rows(
8924 root,
8925 root,
8926 0,
8927 Some(0),
8928 &mut next_row_id,
8929 &mut budget,
8930 &mut stats,
8931 &mut rows,
8932 &mut languages,
8933 include_patterns,
8934 exclude_patterns,
8935 )?;
8936
8937 let root_size = format_dir_size(dir_size_bytes(root));
8938
8939 let mut out = String::new();
8940 write!(
8941 out,
8942 r#"<div class="explorer-wrap" data-project-size="{}">"#,
8943 escape_html(&root_size)
8944 )
8945 .ok();
8946 out.push_str(r#"<div class="explorer-toolbar compact">"#);
8947 out.push_str(r#"<div class="explorer-title-group">"#);
8948 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8949 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8950 out.push_str(r"</div></div>");
8951
8952 out.push_str(r#"<div class="scope-stats">"#);
8953 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();
8954 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();
8955 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();
8956 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();
8957 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();
8958 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>"#);
8959 out.push_str(r"</div>");
8960
8961 let submodules = sloc_core::detect_submodules(root);
8962 if !submodules.is_empty() {
8963 let count = submodules.len();
8964 out.push_str(r#"<div class="submodule-preview-strip">"#);
8965 write!(
8966 out,
8967 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>"#,
8968 count,
8969 if count == 1 { "" } else { "s" }
8970 )
8971 .ok();
8972 out.push_str(r#"<div class="submodule-preview-chips">"#);
8973 for (sub_name, sub_rel_path) in &submodules {
8974 let sub_abs = root.join(sub_rel_path);
8975 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8976 let mut sub_stats = PreviewStats::default();
8977 let mut sub_rows: Vec<PreviewRow> = Vec::new();
8978 let mut sub_langs: Vec<&'static str> = Vec::new();
8979 let mut sub_budget = PreviewBudget {
8980 shown: 0,
8981 max_entries: 2000,
8982 max_depth: 9,
8983 };
8984 let mut sub_next_id = 1usize;
8985 let _ = collect_preview_rows(
8986 &sub_abs,
8987 &sub_abs,
8988 0,
8989 None,
8990 &mut sub_next_id,
8991 &mut sub_budget,
8992 &mut sub_stats,
8993 &mut sub_rows,
8994 &mut sub_langs,
8995 &[],
8996 &[],
8997 );
8998 let stats_json = format!(
8999 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
9000 sub_stats.directories,
9001 sub_stats.files,
9002 sub_stats.supported,
9003 sub_stats.skipped,
9004 sub_stats.unsupported
9005 );
9006 write!(
9007 out,
9008 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>"#,
9009 escape_html(sub_name),
9010 escape_html(&sub_rel_path.to_string_lossy()),
9011 escape_html(&sub_size),
9012 escape_html(&stats_json),
9013 escape_html(sub_name),
9014 escape_html(&sub_size),
9015 )
9016 .ok();
9017 }
9018 out.push_str(r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#);
9019 out.push_str(r"</div>");
9020 }
9021
9022 out.push_str(r#"<div class="scope-info-row">"#);
9023 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
9024 if languages.is_empty() {
9025 out.push_str(
9026 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
9027 );
9028 } else {
9029 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
9030 for language in &languages {
9031 if let Some(icon) = language_icon_file(language) {
9032 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();
9033 } else if let Some(svg) = language_inline_svg(language) {
9034 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();
9035 } else {
9036 write!(
9037 out,
9038 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
9039 escape_html(&language.to_ascii_lowercase()),
9040 escape_html(language)
9041 )
9042 .ok();
9043 }
9044 }
9045 }
9046 out.push_str(r"</div></div>");
9047 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>"#);
9048 out.push_str(r"</div>");
9049
9050 out.push_str(r#"<div class="file-explorer-shell">"#);
9051 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>"#);
9052 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>"#);
9053 out.push_str(r#"<div class="file-explorer-tree">"#);
9054 for row in rows {
9055 let status_label = row.kind.label();
9056 let lang_attr = row.language.unwrap_or("");
9057 let toggle_html = if row.is_dir {
9058 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
9059 .to_string()
9060 } else {
9061 r#"<span class="tree-bullet">•</span>"#.to_string()
9062 };
9063 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();
9064 }
9065 if budget.shown >= budget.max_entries {
9066 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>"#);
9067 }
9068 out.push_str(r"</div></div></div>");
9069
9070 Ok(out)
9071}
9072
9073#[derive(Default)]
9074struct PreviewStats {
9075 directories: usize,
9076 files: usize,
9077 supported: usize,
9078 skipped: usize,
9079 unsupported: usize,
9080}
9081
9082struct PreviewRow {
9083 row_id: usize,
9084 parent_row_id: Option<usize>,
9085 depth: usize,
9086 name: String,
9087 kind: PreviewKind,
9088 is_dir: bool,
9089 language: Option<&'static str>,
9090 modified: String,
9091 type_label: String,
9092}
9093
9094#[derive(Copy, Clone)]
9095enum PreviewKind {
9096 Dir,
9097 Supported,
9098 Skipped,
9099 Unsupported,
9100}
9101
9102impl PreviewKind {
9103 const fn filter_key(self) -> &'static str {
9104 match self {
9105 Self::Dir => "dir",
9106 Self::Supported => "supported",
9107 Self::Skipped => "skipped",
9108 Self::Unsupported => "unsupported",
9109 }
9110 }
9111
9112 const fn label(self) -> &'static str {
9113 match self {
9114 Self::Dir => "dir",
9115 Self::Supported => "supported",
9116 Self::Skipped => "skipped by policy",
9117 Self::Unsupported => "unsupported",
9118 }
9119 }
9120
9121 const fn badge_class(self) -> &'static str {
9122 match self {
9123 Self::Dir => "badge badge-dir",
9124 Self::Supported => "badge badge-scan",
9125 Self::Skipped => "badge badge-skip",
9126 Self::Unsupported => "badge badge-unsupported",
9127 }
9128 }
9129
9130 const fn node_class(self) -> &'static str {
9131 match self {
9132 Self::Dir => "tree-node-dir",
9133 Self::Supported => "tree-node-supported",
9134 Self::Skipped => "tree-node-skipped",
9135 Self::Unsupported => "tree-node-unsupported",
9136 }
9137 }
9138}
9139
9140struct PreviewBudget {
9141 shown: usize,
9142 max_entries: usize,
9143 max_depth: usize,
9144}
9145
9146#[allow(clippy::too_many_arguments)]
9149fn handle_preview_dir_entry(
9150 root: &Path,
9151 path: &Path,
9152 name: &str,
9153 modified: String,
9154 depth: usize,
9155 parent_row_id: Option<usize>,
9156 row_id: usize,
9157 next_row_id: &mut usize,
9158 budget: &mut PreviewBudget,
9159 stats: &mut PreviewStats,
9160 rows: &mut Vec<PreviewRow>,
9161 languages: &mut Vec<&'static str>,
9162 include_patterns: &[String],
9163 exclude_patterns: &[String],
9164) -> Result<()> {
9165 let relative = preview_relative_path(root, path);
9166 if should_skip_preview_directory(&relative, exclude_patterns) {
9167 return Ok(());
9168 }
9169 stats.directories += 1;
9170 rows.push(PreviewRow {
9171 row_id,
9172 parent_row_id,
9173 depth: depth + 1,
9174 name: format!("{name}/"),
9175 kind: PreviewKind::Dir,
9176 is_dir: true,
9177 language: None,
9178 modified,
9179 type_label: "Directory".to_string(),
9180 });
9181 budget.shown += 1;
9182 if !matches!(name, ".git" | "node_modules" | "target") {
9183 collect_preview_rows(
9184 root,
9185 path,
9186 depth + 1,
9187 Some(row_id),
9188 next_row_id,
9189 budget,
9190 stats,
9191 rows,
9192 languages,
9193 include_patterns,
9194 exclude_patterns,
9195 )?;
9196 }
9197 Ok(())
9198}
9199
9200#[allow(clippy::too_many_arguments)]
9202fn handle_preview_file_entry(
9203 root: &Path,
9204 path: &Path,
9205 name: &str,
9206 modified: String,
9207 depth: usize,
9208 parent_row_id: Option<usize>,
9209 row_id: usize,
9210 budget: &mut PreviewBudget,
9211 stats: &mut PreviewStats,
9212 rows: &mut Vec<PreviewRow>,
9213 languages: &mut Vec<&'static str>,
9214 include_patterns: &[String],
9215 exclude_patterns: &[String],
9216) {
9217 let relative = preview_relative_path(root, path);
9218 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
9219 return;
9220 }
9221 stats.files += 1;
9222 let kind = classify_preview_file(name);
9223 match kind {
9224 PreviewKind::Supported => stats.supported += 1,
9225 PreviewKind::Skipped => stats.skipped += 1,
9226 PreviewKind::Unsupported => stats.unsupported += 1,
9227 PreviewKind::Dir => {}
9228 }
9229 let language = detect_language_name(name);
9230 if let Some(lang) = language {
9231 if !languages.contains(&lang) {
9232 languages.push(lang);
9233 }
9234 }
9235 rows.push(PreviewRow {
9236 row_id,
9237 parent_row_id,
9238 depth: depth + 1,
9239 name: name.to_owned(),
9240 kind,
9241 is_dir: false,
9242 language,
9243 modified,
9244 type_label: preview_type_label(name, language, kind),
9245 });
9246 budget.shown += 1;
9247}
9248
9249#[allow(clippy::too_many_arguments)]
9250#[allow(clippy::too_many_lines)]
9251fn collect_preview_rows(
9252 root: &Path,
9254 dir: &Path,
9255 depth: usize,
9256 parent_row_id: Option<usize>,
9257 next_row_id: &mut usize,
9258 budget: &mut PreviewBudget,
9259 stats: &mut PreviewStats,
9260 rows: &mut Vec<PreviewRow>,
9261 languages: &mut Vec<&'static str>,
9262 include_patterns: &[String],
9263 exclude_patterns: &[String],
9264) -> Result<()> {
9265 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9266 return Ok(());
9267 }
9268
9269 let mut entries = fs::read_dir(dir)
9270 .with_context(|| format!("failed to read directory {}", dir.display()))?
9271 .filter_map(std::result::Result::ok)
9272 .collect::<Vec<_>>();
9273 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9274
9275 for entry in entries {
9276 if budget.shown >= budget.max_entries {
9277 break;
9278 }
9279
9280 let path = entry.path();
9281 let name = entry.file_name().to_string_lossy().into_owned();
9282 let Ok(metadata) = entry.metadata() else {
9283 continue;
9284 };
9285 let row_id = *next_row_id;
9286 *next_row_id += 1;
9287 let modified = metadata
9288 .modified()
9289 .ok()
9290 .map_or_else(|| "-".to_string(), format_system_time);
9291
9292 if metadata.is_dir() {
9293 handle_preview_dir_entry(
9294 root,
9295 &path,
9296 &name,
9297 modified,
9298 depth,
9299 parent_row_id,
9300 row_id,
9301 next_row_id,
9302 budget,
9303 stats,
9304 rows,
9305 languages,
9306 include_patterns,
9307 exclude_patterns,
9308 )?;
9309 continue;
9310 }
9311
9312 if metadata.is_file() {
9313 handle_preview_file_entry(
9314 root,
9315 &path,
9316 &name,
9317 modified,
9318 depth,
9319 parent_row_id,
9320 row_id,
9321 budget,
9322 stats,
9323 rows,
9324 languages,
9325 include_patterns,
9326 exclude_patterns,
9327 );
9328 }
9329 }
9330
9331 Ok(())
9332}
9333
9334fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9335 if let Some(language) = language {
9336 return format!("{language} source");
9337 }
9338 let lower = name.to_ascii_lowercase();
9339 let ext = Path::new(&lower)
9340 .extension()
9341 .and_then(|e| e.to_str())
9342 .unwrap_or("");
9343 match kind {
9344 PreviewKind::Skipped => {
9345 if lower.ends_with(".min.js") {
9346 "Minified asset".to_string()
9347 } else if [
9348 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9349 ]
9350 .contains(&ext)
9351 {
9352 "Binary or archive".to_string()
9353 } else {
9354 "Skipped file".to_string()
9355 }
9356 }
9357 PreviewKind::Unsupported => {
9358 if ext.is_empty() {
9359 "Unsupported file".to_string()
9360 } else {
9361 format!("{} file", ext.to_ascii_uppercase())
9362 }
9363 }
9364 PreviewKind::Supported => "Supported source".to_string(),
9365 PreviewKind::Dir => "Directory".to_string(),
9366 }
9367}
9368
9369fn format_system_time(time: SystemTime) -> String {
9370 #[allow(clippy::cast_possible_wrap)]
9371 let secs = match time.duration_since(UNIX_EPOCH) {
9372 Ok(duration) => duration.as_secs() as i64,
9373 Err(_) => return "-".to_string(),
9374 };
9375 let days = secs.div_euclid(86_400);
9376 let secs_of_day = secs.rem_euclid(86_400);
9377 let (year, month, day) = civil_from_days(days);
9378 let hour = secs_of_day / 3_600;
9379 let minute = (secs_of_day % 3_600) / 60;
9380 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9381}
9382
9383#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9384fn civil_from_days(days: i64) -> (i32, u32, u32) {
9385 let z = days + 719_468;
9386 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9387 let doe = z - era * 146_097;
9388 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9389 let y = yoe + era * 400;
9390 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9391 let mp = (5 * doy + 2) / 153;
9392 let d = doy - (153 * mp + 2) / 5 + 1;
9393 let m = mp + if mp < 10 { 3 } else { -9 };
9394 let year = y + i64::from(m <= 2);
9395 (year as i32, m as u32, d as u32)
9396}
9397
9398#[allow(clippy::case_sensitive_file_extension_comparisons)]
9401fn detect_language_name(name: &str) -> Option<&'static str> {
9402 let lower = name.to_ascii_lowercase();
9403 if lower.ends_with(".c") || lower.ends_with(".h") {
9404 Some("C")
9405 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9406 .iter()
9407 .any(|s| lower.ends_with(s))
9408 {
9409 Some("C++")
9410 } else if lower.ends_with(".cs") {
9411 Some("C#")
9412 } else if lower.ends_with(".py") {
9413 Some("Python")
9414 } else if lower.ends_with(".sh") {
9415 Some("Shell")
9416 } else if [".ps1", ".psm1", ".psd1"]
9417 .iter()
9418 .any(|s| lower.ends_with(s))
9419 {
9420 Some("PowerShell")
9421 } else {
9422 None
9423 }
9424}
9425
9426fn language_icon_file(language: &str) -> Option<&'static str> {
9427 match language {
9428 "C" => Some("c.png"),
9429 "C++" => Some("cpp.png"),
9430 "C#" => Some("c-sharp.png"),
9431 "Python" => Some("python.png"),
9432 "Shell" => Some("shell.png"),
9433 "PowerShell" => Some("powershell.png"),
9434 "JavaScript" => Some("java-script.png"),
9435 "HTML" => Some("html-5.png"),
9436 "Java" => Some("java.png"),
9437 "Visual Basic" => Some("visual-basic.png"),
9438 "Assembly" => Some("asm.png"),
9439 "Go" => Some("go.png"),
9440 "R" => Some("r.png"),
9441 "XML" => Some("xml.png"),
9442 "Groovy" => Some("groovy.png"),
9443 "Dockerfile" => Some("docker.png"),
9444 "Makefile" => Some("makefile.svg"),
9445 "Perl" => Some("perl.svg"),
9446 _ => None,
9447 }
9448}
9449
9450fn language_inline_svg(language: &str) -> Option<&'static str> {
9455 match language {
9456 "Rust" => Some(
9457 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>"##,
9458 ),
9459 "TypeScript" => Some(
9460 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>"##,
9461 ),
9462 _ => None,
9463 }
9464}
9465
9466#[allow(clippy::case_sensitive_file_extension_comparisons)]
9469fn classify_preview_file(name: &str) -> PreviewKind {
9470 let lower = name.to_ascii_lowercase();
9471
9472 let scannable = [
9473 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9474 ".psm1", ".psd1",
9475 ]
9476 .iter()
9477 .any(|suffix| lower.ends_with(suffix));
9478
9479 if scannable {
9480 PreviewKind::Supported
9481 } else if lower.ends_with(".min.js")
9482 || lower.ends_with(".lock")
9483 || lower.ends_with(".png")
9484 || lower.ends_with(".jpg")
9485 || lower.ends_with(".jpeg")
9486 || lower.ends_with(".gif")
9487 || lower.ends_with(".zip")
9488 || lower.ends_with(".pdf")
9489 || lower.ends_with(".pyc")
9490 || lower.ends_with(".xz")
9491 || lower.ends_with(".tar")
9492 || lower.ends_with(".gz")
9493 {
9494 PreviewKind::Skipped
9495 } else {
9496 PreviewKind::Unsupported
9497 }
9498}
9499
9500fn preview_relative_path(root: &Path, path: &Path) -> String {
9501 path.strip_prefix(root)
9502 .ok()
9503 .unwrap_or(path)
9504 .to_string_lossy()
9505 .replace('\\', "/")
9506 .trim_matches('/')
9507 .to_string()
9508}
9509
9510fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9511 if relative.is_empty() {
9512 return false;
9513 }
9514
9515 exclude_patterns.iter().any(|pattern| {
9516 wildcard_match(pattern, relative)
9517 || wildcard_match(pattern, &format!("{relative}/"))
9518 || wildcard_match(pattern, &format!("{relative}/placeholder"))
9519 })
9520}
9521
9522fn should_include_preview_file(
9523 relative: &str,
9524 include_patterns: &[String],
9525 exclude_patterns: &[String],
9526) -> bool {
9527 if relative.is_empty() {
9528 return true;
9529 }
9530
9531 let included = include_patterns.is_empty()
9532 || include_patterns
9533 .iter()
9534 .any(|pattern| wildcard_match(pattern, relative));
9535 let excluded = exclude_patterns
9536 .iter()
9537 .any(|pattern| wildcard_match(pattern, relative));
9538
9539 included && !excluded
9540}
9541
9542fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9543 let pattern = pattern.trim().replace('\\', "/");
9544 let candidate = candidate.trim().replace('\\', "/");
9545 let p = pattern.as_bytes();
9546 let c = candidate.as_bytes();
9547 let mut pi = 0usize;
9548 let mut ci = 0usize;
9549 let mut star: Option<usize> = None;
9550 let mut star_match = 0usize;
9551
9552 while ci < c.len() {
9553 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9554 pi += 1;
9555 ci += 1;
9556 } else if pi < p.len() && p[pi] == b'*' {
9557 while pi < p.len() && p[pi] == b'*' {
9558 pi += 1;
9559 }
9560 star = Some(pi);
9561 star_match = ci;
9562 } else if let Some(star_pi) = star {
9563 star_match += 1;
9564 ci = star_match;
9565 pi = star_pi;
9566 } else {
9567 return false;
9568 }
9569 }
9570
9571 while pi < p.len() && p[pi] == b'*' {
9572 pi += 1;
9573 }
9574
9575 pi == p.len()
9576}
9577
9578fn escape_html(value: &str) -> String {
9579 value
9580 .replace('&', "&")
9581 .replace('<', "<")
9582 .replace('>', ">")
9583 .replace('"', """)
9584 .replace('\'', "'")
9585}
9586
9587#[derive(Clone)]
9588struct SubmoduleRow {
9589 name: String,
9590 relative_path: String,
9591 files_analyzed: u64,
9592 code_lines: u64,
9593 comment_lines: u64,
9594 blank_lines: u64,
9595 total_physical_lines: u64,
9596 html_url: Option<String>,
9597}
9598
9599#[derive(Template)]
9600#[template(
9601 source = r##"
9602<!doctype html>
9603<html lang="en">
9604<head>
9605 <meta charset="utf-8">
9606 <title>OxideSLOC | tmp-sloc</title>
9607 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9608 <style nonce="{{ csp_nonce }}">
9609 :root {
9610 --bg: #efe9e2;
9611 --surface: #fcfaf7;
9612 --surface-2: #f7f0e8;
9613 --surface-3: #efe3d5;
9614 --line: #dfcfbf;
9615 --line-strong: #cfb29c;
9616 --text: #2f241c;
9617 --muted: #6f6257;
9618 --muted-2: #917f71;
9619 --nav: #b85d33;
9620 --nav-2: #7a371b;
9621 --accent: #2563eb;
9622 --accent-2: #1d4ed8;
9623 --oxide: #b85d33;
9624 --oxide-2: #8f4220;
9625 --success-bg: #eaf9ee;
9626 --success-text: #1c8746;
9627 --warn-bg: #fff2d8;
9628 --warn-text: #926000;
9629 --danger-bg: #fdeaea;
9630 --danger-text: #b33b3b;
9631 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9632 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9633 --radius: 14px;
9634 }
9635
9636 body.dark-theme {
9637 --bg: #1b1511;
9638 --surface: #261c17;
9639 --surface-2: #2d221d;
9640 --surface-3: #372922;
9641 --line: #524238;
9642 --line-strong: #6c5649;
9643 --text: #f5ece6;
9644 --muted: #c7b7aa;
9645 --muted-2: #aa9485;
9646 --nav: #b85d33;
9647 --nav-2: #7a371b;
9648 --accent: #6f9bff;
9649 --accent-2: #4a78ee;
9650 --oxide: #d37a4c;
9651 --oxide-2: #b35428;
9652 --success-bg: #163927;
9653 --success-text: #8fe2a8;
9654 --warn-bg: #3c2d11;
9655 --warn-text: #f3cb75;
9656 --danger-bg: #3d1f1f;
9657 --danger-text: #ff9f9f;
9658 --shadow: 0 14px 28px rgba(0,0,0,0.28);
9659 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9660 }
9661
9662 * { box-sizing: border-box; }
9663 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); }
9664 html { overflow-y: scroll; }
9665 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9666 .top-nav, .page, .loading { position: relative; z-index: 2; }
9667 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9668 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9669 .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); }
9670 .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; }
9671 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9672 .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)); }
9673 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9674 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9675 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9676 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9677 .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; }
9678 .nav-project-pill.visible { display:inline-flex; }
9679 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9680 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9681 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9682 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9683 @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; } }
9684 .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; }
9685 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9686 .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; }
9687 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9688 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9689 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9690 .theme-toggle .icon-sun { display:none; }
9691 body.dark-theme .theme-toggle .icon-sun { display:block; }
9692 body.dark-theme .theme-toggle .icon-moon { display:none; }
9693 .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;}
9694 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9695 .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);}
9696 .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;}
9697 .settings-close:hover{color:var(--text);background:var(--surface-2);}
9698 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9699 .settings-modal-body{padding:14px 16px 16px;}
9700 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9701 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9702 .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;}
9703 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9704 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9705 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9706 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9707 .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;}
9708 .tz-select:focus{border-color:var(--oxide);}
9709 .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; }
9710 .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;}
9711 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9712 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9713 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9714 .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; }
9715 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9716 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9717 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9718 .wb-stats-header { padding: 10px 24px 0; }
9719 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9720 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9721 .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; }
9722 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9723 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9724 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9725 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9726 .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; }
9727 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9728 .ws-stat-analyzers { position: relative; }
9729 .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; }
9730 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9731 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9732 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9733 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9734 .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; }
9735 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9736 .ws-divider { display: none; }
9737 .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%; }
9738 .ws-path-link:hover { color:var(--oxide); }
9739 body.dark-theme .ws-path-link { color:var(--oxide); }
9740 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9741 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9742 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9743 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9744 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9745 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9746 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9747 .ws-mini-box-lg { flex:2 1 0; }
9748 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9749 .ws-mini-box-br { flex:1.5 1 0; }
9750 .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); }
9751 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9752 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9753 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9754 .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; }
9755 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9756 .git-source-banner strong { font-weight:800; color:var(--text); }
9757 .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; }
9758 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9759 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9760 .git-source-banner a:hover { text-decoration:underline; }
9761 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9762 .path-scope-sep { background:var(--line); margin:4px 14px; }
9763 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9764 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9765 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9766 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9767 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9768 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9769 .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; }
9770 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9771 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9772 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9773 .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; }
9774 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9775 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9776 [data-wb-tip] { cursor:help; }
9777 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9778 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9779 .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; }
9780 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9781 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9782 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9783 .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; }
9784 .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); }
9785 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9786 .side-info-card { padding: 18px; }
9787 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9788 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9789 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9790 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9791 .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); }
9792 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9793 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9794 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9795 .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; }
9796 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9797 .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; }
9798 .side-stack::-webkit-scrollbar { display: none; }
9799 .step-nav { padding: 20px 16px; }
9800 .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); }
9801 .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; }
9802 .step-button:hover { background: var(--surface-2); }
9803 .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); }
9804 .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; }
9805 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9806 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9807 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9808 .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); }
9809 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9810 .step-nav-sum-row:last-child { border-bottom:none; }
9811 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9812 .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; }
9813 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9814 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9815 .quick-scan-section { padding: 10px 4px 14px; }
9816 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9817 .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; }
9818 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9819 .quick-scan-btn:active { transform:translateY(0); }
9820 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9821 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9822 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9823 @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);} }
9824 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9825 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9826 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9827 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9828 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9829 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9830 .step-button.done .step-check { opacity:1; }
9831 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9832 .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; }
9833 .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; }
9834 .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; }
9835 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9836 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9837 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9838 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9839 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9840 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9841 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9842 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9843 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9844 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9845 .card-body { padding: 22px; }
9846 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9847 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9848 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9849 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9850 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9851 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9852 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9853 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9854 .field { min-width:0; }
9855 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9856 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; }
9857 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); }
9858 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9859 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); }
9860 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9861 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9862 .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; }
9863 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9864 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9865 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9866 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9867 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9868 .input-group.compact { grid-template-columns: 1fr auto auto; }
9869 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9870 .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)); }
9871 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9872 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9873 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9874 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9875 .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; }
9876 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9877 .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; }
9878 .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); }
9879 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9880 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9881 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9882 button.secondary { background: var(--surface); }
9883 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9884 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9885 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9886 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9887 .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); }
9888 .section + .wizard-actions { border-top: none; padding-top: 0; }
9889 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9890 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9891 .field-help-grid.coupled-help { margin-top: 12px; }
9892 .field-help-grid.preset-grid { align-items: start; }
9893 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9894 .preset-inline-row .field { margin: 0; }
9895 .preset-inline-row .explainer-card { margin: 0; }
9896 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9897 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9898 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9899 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9900 .preset-kv-row > :last-child { flex:1; min-width:0; }
9901 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9902 .output-field-row .field { margin: 0; }
9903 .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; }
9904 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9905 .step3-subtitle { margin-bottom: 10px; max-width: none; }
9906 .counting-intro { margin-bottom: 8px; max-width: none; }
9907 .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; }
9908 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9909 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9910 .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; }
9911 .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; }
9912 .section-spacer-top { margin-top: 28px; }
9913 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9914 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9915 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9916 .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); }
9917 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9918 .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; }
9919 .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; }
9920 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9921 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9922 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9923 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9924 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9925 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9926 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9927 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9928 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9929 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9930 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9931 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9932 .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); }
9933 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9934 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9935 .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; }
9936 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9937 .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; }
9938 .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; }
9939 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9940 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9941 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9942 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9943 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9944 .advanced-rule-description strong { color: var(--text); }
9945 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9946 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9947 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9948 .review-link:hover { text-decoration: underline; }
9949 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9950 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9951 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9952 .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; }
9953 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9954 .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9955 .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9956 body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9957 .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9958 body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9959 .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; }
9960 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9961 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9962 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9963 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9964 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9965 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9966 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9967 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9968 .review-card ul { padding-left: 18px; margin: 0; }
9969 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9970 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9971 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9972 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9973 .review-card { min-height: 200px; }
9974 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9975 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9976 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9977 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9978 .lang-overflow-chip { position:relative; cursor:default; }
9979 .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; }
9980 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9981 .git-inline-row { align-items:start; }
9982 .mixed-line-card { display:flex; flex-direction:column; }
9983 .preset-inline-row .toggle-card { justify-content: center; }
9984 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9985 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9986 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9987 .explorer-title { font-size: 18px; font-weight: 850; }
9988 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9989 .explorer-subtitle.wide { max-width: none; }
9990 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9991 .better-spacing { align-items:flex-start; justify-content:flex-end; }
9992 .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; }
9993 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9994 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9995 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9996 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9997 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9998 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9999 .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; }
10000 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
10001 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
10002 .scope-stat-button.supported { background: var(--success-bg); }
10003 .scope-stat-button.skipped { background: var(--warn-bg); }
10004 .scope-stat-button.unsupported { background: var(--danger-bg); }
10005 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
10006 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
10007 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
10008 [data-tooltip] { position: relative; }
10009 [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); }
10010 [data-tooltip]:hover::after { display: block; }
10011 .scope-stat-button[data-tooltip] { cursor: pointer; }
10012 .badge[data-tooltip] { cursor: help; }
10013 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
10014 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
10015 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
10016 .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; }
10017 .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; }
10018 code { display:inline-block; margin-top:0; padding:2px 7px; }
10019 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
10020 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
10021 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
10022 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
10023 .language-pill.muted-pill { color: var(--muted); }
10024 button.language-pill { appearance:none; cursor:pointer; }
10025 .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); }
10026 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
10027 .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; }
10028 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
10029 .file-explorer-search-row { margin-left: auto; }
10030 .explorer-filter-select { min-width: 170px; width: 170px; }
10031 .explorer-search { min-width: 300px; width: 300px; }
10032 .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); }
10033 .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; }
10034 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
10035 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
10036 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
10037 .file-explorer-tree { max-height: 640px; overflow:auto; }
10038 .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); }
10039 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
10040 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
10041 .tree-row.hidden-by-filter { display:none !important; }
10042 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
10043 .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; }
10044 .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; }
10045 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
10046 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
10047 .tree-node { display:inline-flex; align-items:center; min-width:0; }
10048 .tree-node-dir { color: var(--text); font-weight: 800; }
10049 .tree-node-supported { color: var(--success-text); }
10050 .tree-node-skipped { color: var(--warn-text); }
10051 .tree-node-unsupported { color: var(--danger-text); }
10052 .tree-node-more { color: var(--muted-2); font-style: italic; }
10053 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
10054 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
10055 .tree-status-cell { display:flex; justify-content:flex-start; }
10056 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
10057 .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; }
10058 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
10059 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
10060 .cov-scan-idle { display:none; }
10061 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
10062 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
10063 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
10064 .cov-scan-title { font-weight:600; font-size:12.5px; }
10065 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
10066 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
10067 .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; }
10068 .cov-scan-use:hover { opacity:.75; }
10069 .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; }
10070 .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; }
10071 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
10072 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
10073 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
10074 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
10075 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
10076 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
10077 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
10078 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
10079 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
10080 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
10081 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
10082 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
10083 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
10084 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
10085 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
10086 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
10087 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
10088 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
10089 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
10090 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
10091 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
10092 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
10093 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
10094 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
10095 .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); }
10096 .loading.active { display:flex; }
10097 .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; }
10098 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
10099 .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; }
10100 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
10101 .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; }
10102 .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; }
10103 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
10104 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
10105 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
10106 .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; }
10107 .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
10108 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
10109 .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
10110 .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
10111 .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; }
10112 .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; }
10113 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
10114 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
10115 .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; }
10116 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
10117 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
10118 .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; }
10119 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
10120 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
10121 .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; }
10122 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
10123 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
10124 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
10125 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
10126 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
10127 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
10128 .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; }
10129 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
10130 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
10131 .hidden { display:none !important; }
10132 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
10133 .site-footer a{color:var(--muted);}
10134 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
10135 @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; } }
10136 .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;}
10137 @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));}}
10138 .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;}
10139 .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; }
10140 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
10141 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
10142 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
10143 .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; }
10144 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
10145 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
10146 .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; }
10147 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
10148 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
10149 .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; }
10150 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
10151 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
10152 .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; }
10153 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
10154 .info-icon-btn:hover { color:var(--text); }
10155 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); }
10156 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
10157 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
10158 </style>
10159</head>
10160<body>
10161 <div class="background-watermarks" aria-hidden="true">
10162 <img src="/images/logo/logo-text.png" alt="" />
10163 <img src="/images/logo/logo-text.png" alt="" />
10164 <img src="/images/logo/logo-text.png" alt="" />
10165 <img src="/images/logo/logo-text.png" alt="" />
10166 <img src="/images/logo/logo-text.png" alt="" />
10167 <img src="/images/logo/logo-text.png" alt="" />
10168 <img src="/images/logo/logo-text.png" alt="" />
10169 <img src="/images/logo/logo-text.png" alt="" />
10170 <img src="/images/logo/logo-text.png" alt="" />
10171 <img src="/images/logo/logo-text.png" alt="" />
10172 <img src="/images/logo/logo-text.png" alt="" />
10173 <img src="/images/logo/logo-text.png" alt="" />
10174 <img src="/images/logo/logo-text.png" alt="" />
10175 <img src="/images/logo/logo-text.png" alt="" />
10176 </div>
10177 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10178 <div class="top-nav">
10179 <div class="top-nav-inner">
10180 <a class="brand" href="/">
10181 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
10182 <div class="brand-copy">
10183 <div class="brand-title">OxideSLOC</div>
10184 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
10185 </div>
10186 </a>
10187 <div class="nav-project-slot">
10188 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
10189 <span class="nav-project-label">Project</span>
10190 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
10191 </div>
10192 </div>
10193 <div class="nav-status">
10194 <a class="nav-pill" href="/">Home</a>
10195 <div class="nav-dropdown">
10196 <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>
10197 <div class="nav-dropdown-menu">
10198 <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>
10199 </div>
10200 </div>
10201 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10202 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10203 <div class="nav-dropdown">
10204 <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>
10205 <div class="nav-dropdown-menu">
10206 <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>
10207 </div>
10208 </div>
10209 <div class="server-status-wrap">
10210 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
10211 <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>
10212 </div>
10213 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10214 <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>
10215 </button>
10216 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
10217 <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>
10218 <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>
10219 </button>
10220 </div>
10221 </div>
10222 </div>
10223
10224 <div class="loading" id="loading">
10225 <div class="loading-card">
10226 <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10227 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10228 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10229 <div class="lc-path" id="lc-path"></div>
10230 <div class="lc-metrics" id="lc-metrics">
10231 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10232 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10233 </div>
10234 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10235 <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>
10236 <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>
10237 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10238 <div class="lc-actions hidden" id="lc-actions">
10239 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10240 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10241 </div>
10242 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10243 <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>
10244 Cancel scan
10245 </button>
10246 </div>
10247 </div>
10248
10249 <div class="page">
10250 <div class="workbench-strip">
10251 <div class="workbench-box wb-stats">
10252 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10253 <span class="wb-stats-title">Analysis session</span>
10254 </div>
10255 <div class="ws-left">
10256 <div class="ws-stat ws-stat-analyzers">
10257 <span class="ws-label">Analyzers</span>
10258 <span class="ws-value">
10259 <span class="ws-badge">41 languages</span>
10260 </span>
10261 <div class="ws-lang-tooltip">
10262 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10263 <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>
10264 <div class="ws-lang-grid">
10265 <span class="ws-lang-item">Assembly</span>
10266 <span class="ws-lang-item">C</span>
10267 <span class="ws-lang-item">C++</span>
10268 <span class="ws-lang-item">C#</span>
10269 <span class="ws-lang-item">Clojure</span>
10270 <span class="ws-lang-item">CSS</span>
10271 <span class="ws-lang-item">Dart</span>
10272 <span class="ws-lang-item">Dockerfile</span>
10273 <span class="ws-lang-item">Elixir</span>
10274 <span class="ws-lang-item">Erlang</span>
10275 <span class="ws-lang-item">F#</span>
10276 <span class="ws-lang-item">Go</span>
10277 <span class="ws-lang-item">Groovy</span>
10278 <span class="ws-lang-item">Haskell</span>
10279 <span class="ws-lang-item">HTML</span>
10280 <span class="ws-lang-item">Java</span>
10281 <span class="ws-lang-item">JavaScript</span>
10282 <span class="ws-lang-item">Julia</span>
10283 <span class="ws-lang-item">Kotlin</span>
10284 <span class="ws-lang-item">Lua</span>
10285 <span class="ws-lang-item">Makefile</span>
10286 <span class="ws-lang-item">Nim</span>
10287 <span class="ws-lang-item">Obj-C</span>
10288 <span class="ws-lang-item">OCaml</span>
10289 <span class="ws-lang-item">Perl</span>
10290 <span class="ws-lang-item">PHP</span>
10291 <span class="ws-lang-item">PowerShell</span>
10292 <span class="ws-lang-item">Python</span>
10293 <span class="ws-lang-item">R</span>
10294 <span class="ws-lang-item">Ruby</span>
10295 <span class="ws-lang-item">Rust</span>
10296 <span class="ws-lang-item">Scala</span>
10297 <span class="ws-lang-item">SCSS</span>
10298 <span class="ws-lang-item">Shell</span>
10299 <span class="ws-lang-item">SQL</span>
10300 <span class="ws-lang-item">Svelte</span>
10301 <span class="ws-lang-item">Swift</span>
10302 <span class="ws-lang-item">TypeScript</span>
10303 <span class="ws-lang-item">Vue</span>
10304 <span class="ws-lang-item">XML</span>
10305 <span class="ws-lang-item">Zig</span>
10306 </div>
10307 </div>
10308 </div>
10309 <div class="ws-divider"></div>
10310 <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>
10311 <div class="ws-divider"></div>
10312 <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>
10313 <div class="ws-divider"></div>
10314 <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.">
10315 <span class="ws-label">Output</span>
10316 <span class="ws-value">
10317 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10318 <span id="ws-output-root">project/sloc</span>
10319 </button>
10320 </span>
10321 </div>
10322 </div>
10323 </div>
10324 <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.">
10325 <div class="ws-history-label">Scan history</div>
10326 <div class="ws-history-inner">
10327 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10328 <div class="ws-mini-label">Scans</div>
10329 <div class="ws-mini-value" id="ws-scan-count">—</div>
10330 </div>
10331 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10332 <div class="ws-mini-label">Last Scan</div>
10333 <div class="ws-mini-value" id="ws-last-scan">—</div>
10334 </div>
10335 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10336 <div class="ws-mini-label">Branch</div>
10337 <div class="ws-mini-value" id="ws-branch">—</div>
10338 </div>
10339 </div>
10340 </div>
10341 </div>
10342
10343 <div class="layout">
10344 <aside class="side-stack">
10345 <section class="step-nav">
10346 <h3>Guided scan setup</h3>
10347 <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>
10348 <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>
10349 <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>
10350 <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>
10351
10352 <div class="step-steps-divider"></div>
10353
10354 <div class="step-nav-info" id="step-nav-info">
10355 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10356 <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>
10357 </div>
10358
10359 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10360 <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>
10361 <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>
10362 <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>
10363 </div>
10364
10365 <div class="quick-scan-divider"></div>
10366 <div class="quick-scan-section">
10367 <div class="quick-scan-label">No customization needed?</div>
10368 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10369 <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>
10370 Quick Scan
10371 </button>
10372 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10373 </div>
10374
10375 <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>
10376 </section>
10377
10378 </aside>
10379
10380 <section class="card">
10381 <div class="card-header">
10382 <div class="card-title-row">
10383 <div>
10384 <h1 class="card-title">Guided scan configuration</h1>
10385 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10386 </div>
10387 <div class="wizard-progress" aria-label="Scan setup progress">
10388 <div class="wizard-progress-top">
10389 <span class="wizard-progress-label">Setup progress</span>
10390 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10391 </div>
10392 <div class="wizard-progress-track">
10393 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10394 </div>
10395 </div>
10396 </div>
10397 </div>
10398 <div class="card-body">
10399 <form method="post" action="/analyze" id="analyze-form">
10400 <div class="wizard-step active" data-step="1">
10401 <div class="section">
10402 <div class="section-kicker">Step 1</div>
10403 <h2>Select project and preview scope</h2>
10404 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10405 <div class="field">
10406 <label for="path">Project path</label>
10407 {% if !git_repo.is_empty() %}
10408 <div class="git-source-banner">
10409 <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>
10410 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10411 <a href="/git-browser">← Back to Git Browser</a>
10412 </div>
10413 {% endif %}
10414 <div class="path-scope-grid">
10415 {% if !git_repo.is_empty() %}
10416 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10417 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10418 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10419 {% else %}
10420 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10421 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10422 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10423 {% endif %}
10424 <div class="path-scope-sep"></div>
10425 <div class="scope-legend-row">
10426 <span class="scope-legend-label">Scope legend:</span>
10427 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10428 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10429 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10430 </div>
10431 </div>
10432 {% if git_repo.is_empty() %}
10433 <div class="path-info-row">
10434 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10435 <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>
10436 <span id="project-size-text">Project size: —</span>
10437 </button>
10438 </div>
10439 {% else %}
10440 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10441 {% endif %}
10442 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10443 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10444 </div>
10445
10446 <div class="scope-preview-divider" aria-hidden="true"></div>
10447
10448 <div id="preview-panel">
10449 <div class="preview-error">Loading preview...</div>
10450 </div>
10451 </div>
10452
10453 <div class="section" style="margin-top:14px;">
10454 <div class="preset-inline-row git-inline-row">
10455 <div class="toggle-card" style="margin:0;">
10456 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10457 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10458 <label class="checkbox">
10459 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10460 <div>
10461 <span>Detect and separate git submodules</span>
10462 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10463 </div>
10464 </label>
10465 </div>
10466 <div class="explainer-card prominent" style="margin:0;">
10467 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10468 <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>
10469 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10470 path = libs/core
10471 url = https://github.com/org/core.git
10472
10473[submodule "libs/ui"]
10474 path = libs/ui
10475 url = https://github.com/org/ui.git</div>
10476 </div>
10477 </div>
10478 </div>
10479
10480 <div class="section">
10481 <div class="field-grid">
10482 <div class="field">
10483 <label for="include_globs">Include globs</label>
10484 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
10485 <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>
10486 </div>
10487 <div class="field">
10488 <label for="exclude_globs">Exclude globs</label>
10489 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
10490 <div id="quick-exclude-chips" class="quick-excl-row">
10491 <span class="quick-excl-label">Quick add:</span>
10492 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10493 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10494 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10495 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10496 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10497 <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>
10498 </div>
10499 <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>
10500 </div>
10501 </div>
10502 <div class="glob-guidance-grid">
10503 <div class="glob-guidance-card">
10504 <strong>How to read them</strong>
10505 <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>
10506 </div>
10507 <div class="glob-guidance-card">
10508 <strong>Common include examples</strong>
10509 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10510 </div>
10511 <div class="glob-guidance-card">
10512 <strong>Common exclude examples</strong>
10513 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10514 </div>
10515 </div>
10516 </div>
10517
10518 <div class="section" style="margin-top:14px;">
10519 <div class="preset-inline-row git-inline-row">
10520 <div class="toggle-card" style="margin:0;">
10521 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10522 <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>
10523 <div class="field" style="margin:0;">
10524 <div class="input-group compact">
10525 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10526 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10527 </div>
10528 <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>
10529 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10530 </div>
10531 </div>
10532 <div class="explainer-card prominent" style="margin:0;">
10533 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10534 <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>
10535 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10536lcov --capture --directory . --output-file coverage/lcov.info
10537
10538# C / C++ — llvm-cov (LCOV)
10539llvm-profdata merge -sparse default.profraw -o default.profdata
10540llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10541
10542# C# — coverlet (Cobertura XML)
10543dotnet test --collect:"XPlat Code Coverage"
10544
10545# Python — pytest-cov (Cobertura XML)
10546pytest --cov --cov-report=xml
10547
10548# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10549./gradlew jacocoTestReport</div>
10550 </div>
10551 </div>
10552 </div>
10553
10554 <div class="wizard-actions">
10555 <div class="left"></div>
10556 <div class="right">
10557 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10558 </div>
10559 </div>
10560 </div>
10561
10562 <div class="wizard-step" data-step="2">
10563 <div class="section">
10564 <div class="section-kicker">Step 2</div>
10565 <h2>Choose counting behavior</h2>
10566 <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>
10567 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10568 <div class="subsection-bar">Primary line classification</div>
10569 <div class="preset-kv-row">
10570 <div class="toggle-card mixed-line-card" style="margin:0;">
10571 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10572 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10573 <select id="mixed_line_policy" name="mixed_line_policy">
10574 <option value="code_only">Code only</option>
10575 <option value="code_and_comment">Code and comment</option>
10576 <option value="comment_only">Comment only</option>
10577 <option value="separate_mixed_category">Separate mixed category</option>
10578 </select>
10579 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10580 </div>
10581 <div class="explainer-card prominent" style="margin:0;">
10582 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10583 <div class="explainer-body" id="mixed-policy-description"></div>
10584 <div class="code-sample" id="mixed-policy-example"></div>
10585 </div>
10586 </div>
10587 </div>
10588
10589 <div class="subsection-bar">Additional scan rules</div>
10590 <div class="scan-rules-grid">
10591 <div class="preset-inline-row">
10592 <div class="toggle-card" style="margin:0;">
10593 <div class="field-help-title">Generated files</div>
10594 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10595 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10596 </div>
10597 <div class="explainer-card prominent" style="margin:0;">
10598 <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>
10599 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10600# Files matching codegen patterns are excluded:
10601# *.generated.cs *.pb.go *.g.dart</div>
10602 </div>
10603 </div>
10604 <div class="preset-inline-row">
10605 <div class="toggle-card" style="margin:0;">
10606 <div class="field-help-title">Minified files</div>
10607 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10608 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10609 </div>
10610 <div class="explainer-card prominent" style="margin:0;">
10611 <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>
10612 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10613# Heuristic: very long lines + low whitespace ratio
10614# jquery.min.js bundle.min.css → skipped</div>
10615 </div>
10616 </div>
10617 <div class="preset-inline-row">
10618 <div class="toggle-card" style="margin:0;">
10619 <div class="field-help-title">Vendor directories</div>
10620 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10621 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10622 </div>
10623 <div class="explainer-card prominent" style="margin:0;">
10624 <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>
10625 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10626# Directories named vendor/ node_modules/ third_party/
10627# → entire subtree is excluded from totals</div>
10628 </div>
10629 </div>
10630 <div class="preset-inline-row">
10631 <div class="toggle-card" style="margin:0;">
10632 <div class="field-help-title">Lockfiles and manifests</div>
10633 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10634 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10635 </div>
10636 <div class="explainer-card prominent" style="margin:0;">
10637 <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>
10638 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
10639# Files like package-lock.json Cargo.lock yarn.lock
10640# → skipped unless this is enabled</div>
10641 </div>
10642 </div>
10643 <div class="preset-inline-row">
10644 <div class="toggle-card" style="margin:0;">
10645 <div class="field-help-title">Binary handling</div>
10646 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10647 <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>
10648 </div>
10649 <div class="explainer-card prominent" style="margin:0;">
10650 <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>
10651 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
10652# Detected via long lines + low whitespace heuristic
10653# .png .exe .so → skipped silently</div>
10654 </div>
10655 </div>
10656 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10657 <div class="toggle-card" style="margin:0;">
10658 <div class="field-help-title">Python docstrings</div>
10659 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10660 <label class="checkbox">
10661 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10662 <span>Count as comment-style lines</span>
10663 </label>
10664 </div>
10665 <div class="explainer-card prominent" style="margin:0;">
10666 <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>
10667 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10668 </div>
10669 </div>
10670 </div>
10671 <div class="always-tracked-tip">
10672 <div class="always-tracked-tip-icon">ℹ</div>
10673 <div class="always-tracked-tip-body">
10674 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
10675 <h4>Comment and blank-line basics & Lines on the boundary</h4>
10676 <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>
10677 </div>
10678 </div>
10679
10680 <div class="wizard-actions">
10681 <div class="left">
10682 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10683 </div>
10684 <div class="right">
10685 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10686 </div>
10687 </div>
10688 </div>
10689
10690 <div class="wizard-step" data-step="3">
10691 <div class="section">
10692 <div class="section-kicker">Step 3</div>
10693 <h2>Output and report identity</h2>
10694 <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>
10695 <div class="preset-kv-row">
10696 <div class="toggle-card" style="margin:0;">
10697 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10698 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10699 <select id="scan_preset">
10700 <option value="balanced">Balanced local scan</option>
10701 <option value="code_focused">Code focused</option>
10702 <option value="comment_audit">Comment audit</option>
10703 <option value="deep_review">Deep review</option>
10704 </select>
10705 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10706 </div>
10707 <div class="explainer-card">
10708 <div class="field-help-title">Selected scan preset</div>
10709 <div class="explainer-body" id="scan-preset-description"></div>
10710 <div class="preset-summary-row" id="scan-preset-summary"></div>
10711 <div class="code-sample" id="scan-preset-example"></div>
10712 <div class="preset-note" id="scan-preset-note"></div>
10713 </div>
10714 </div>
10715 <hr class="step3-separator" />
10716 <div class="preset-kv-row">
10717 <div class="toggle-card" style="margin:0;">
10718 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10719 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10720 <select id="artifact_preset">
10721 <option value="review">Review bundle</option>
10722 <option value="full">Full bundle</option>
10723 <option value="html_only">HTML only</option>
10724 <option value="machine">Machine bundle</option>
10725 </select>
10726 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10727 </div>
10728 <div class="explainer-card">
10729 <div class="field-help-title">Selected artifact preset</div>
10730 <div class="explainer-body" id="artifact-preset-description"></div>
10731 <div class="preset-summary-row" id="artifact-preset-summary"></div>
10732 <div class="code-sample" id="artifact-preset-example"></div>
10733 </div>
10734 </div>
10735 </div>
10736
10737 <div class="section section-spacer-top">
10738 <div class="output-field-row">
10739 <div class="field">
10740 <label for="output_dir">Output directory</label>
10741 <div class="input-group compact">
10742 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10743 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10744 <button type="button" class="mini-button" id="use-default-output">Use default</button>
10745 </div>
10746 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10747 </div>
10748 <div class="output-field-aside">
10749 <strong>Where reports land</strong>
10750 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.
10751 </div>
10752 </div>
10753 </div>
10754
10755 <div class="section section-spacer-top">
10756 <div class="output-field-row">
10757 <div class="field">
10758 <label for="report_title">Report title</label>
10759 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10760 <div class="hint">Appears in HTML and PDF output headers.</div>
10761 </div>
10762 <div class="output-field-aside">
10763 <strong>Shown in exported artifacts</strong>
10764 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.
10765 </div>
10766 </div>
10767 </div>
10768
10769 <div class="section section-spacer-top">
10770 <div class="output-field-row">
10771 <div class="field">
10772 <label for="report_header_footer">Report header / footer</label>
10773 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10774 <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>
10775 </div>
10776 <div class="output-field-aside">
10777 <strong>Page-level identification</strong>
10778 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.
10779 </div>
10780 </div>
10781 </div>
10782
10783 <div class="section">
10784 <div class="section-kicker">Artifacts</div>
10785 <div class="artifact-grid" style="margin-bottom:24px;">
10786 <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10787 <div class="marker">✓</div>
10788 <div class="artifact-icon">H</div>
10789 <h4>HTML report</h4>
10790 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10791 <div class="artifact-tags">
10792 <span class="soft-chip">Best for visual review</span>
10793 <span class="soft-chip">Embeddable preview</span>
10794 </div>
10795 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10796 </div>
10797 <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10798 <div class="marker">✓</div>
10799 <div class="artifact-icon">P</div>
10800 <h4>PDF export</h4>
10801 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10802 <div class="artifact-tags">
10803 <span class="soft-chip">Portable snapshot</span>
10804 <span class="soft-chip">Good for handoff</span>
10805 </div>
10806 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10807 </div>
10808 <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10809 <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>
10810 <div class="marker">✓</div>
10811 <div class="artifact-icon" style="color:var(--muted);">J</div>
10812 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10813 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10814 <div class="artifact-tags">
10815 <span class="soft-chip">Required for compare</span>
10816 <span class="soft-chip">Auto-enabled</span>
10817 </div>
10818 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10819 </div>
10820 </div>
10821 <div class="hint" style="margin-top:12px;">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
10822 </div>
10823
10824 <div class="wizard-actions">
10825 <div class="left">
10826 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10827 </div>
10828 <div class="right">
10829 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10830 </div>
10831 </div>
10832 </div>
10833
10834 <div class="wizard-step" data-step="4">
10835 <div class="section">
10836 <div class="section-kicker">Step 4</div>
10837 <h2>Review selections and run</h2>
10838 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10839 <div class="review-grid">
10840 <div class="review-card highlight">
10841 <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>
10842 <ul id="review-scan-summary"></ul>
10843 </div>
10844 <div class="review-card highlight">
10845 <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>
10846 <ul id="review-count-summary"></ul>
10847 </div>
10848 <div class="review-card">
10849 <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>
10850 <ul id="review-artifact-summary"></ul>
10851 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10852 </div>
10853 <div class="review-card">
10854 <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>
10855 <ul id="review-preview-summary"></ul>
10856 </div>
10857 </div>
10858 </div>
10859
10860 <div class="wizard-actions">
10861 <div class="left">
10862 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10863 </div>
10864 <div class="right">
10865 <button type="submit" id="submit-button" class="primary">Run analysis</button>
10866 </div>
10867 </div>
10868 </div></form>
10869 </div>
10870 </section>
10871 </div>
10872 </div>
10873
10874 <script nonce="{{ csp_nonce }}">
10875 (function () {
10876 function startScanPhase() {
10877 var phaseEl = document.getElementById("scan-phase");
10878 if (!phaseEl) return;
10879 var phases = [
10880 "Discovering files...",
10881 "Decoding file encodings...",
10882 "Detecting languages...",
10883 "Analyzing source lines...",
10884 "Applying counting policies...",
10885 "Aggregating results...",
10886 "Rendering report..."
10887 ];
10888 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10889 var i = 0;
10890 function next() {
10891 phaseEl.style.opacity = "0";
10892 setTimeout(function () {
10893 phaseEl.textContent = phases[i];
10894 phaseEl.style.opacity = "0.85";
10895 var delay = durations[i] || 1800;
10896 i++;
10897 if (i < phases.length) { setTimeout(next, delay); }
10898 }, 200);
10899 }
10900 next();
10901 }
10902
10903 var form = document.getElementById("analyze-form");
10904 var loading = document.getElementById("loading");
10905 var submitButton = document.getElementById("submit-button");
10906 var pathInput = document.getElementById("path");
10907 var GIT_MODE = !!(pathInput && pathInput.readOnly);
10908 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10909 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10910 var outputDirInput = document.getElementById("output_dir");
10911 var reportTitleInput = document.getElementById("report_title");
10912 var previewPanel = document.getElementById("preview-panel");
10913 var refreshButton = document.getElementById("refresh-preview");
10914 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10915 var useSamplePath = document.getElementById("use-sample-path");
10916 var useDefaultOutput = document.getElementById("use-default-output");
10917 var browsePath = document.getElementById("browse-path");
10918 var browseOutputDir = document.getElementById("browse-output-dir");
10919 var browseCoverage = document.getElementById("browse-coverage");
10920 var coverageInput = document.getElementById("coverage_file");
10921 var covScanStatus = document.getElementById("cov-scan-status");
10922 var coverageSuggestTimer = null;
10923 var covAutoFilled = false;
10924 var themeToggle = document.getElementById("theme-toggle");
10925 var mixedLinePolicy = document.getElementById("mixed_line_policy");
10926 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10927 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10928 var scanPreset = document.getElementById("scan_preset");
10929 var artifactPreset = document.getElementById("artifact_preset");
10930 var includeGlobsInput = document.getElementById("include_globs");
10931 var excludeGlobsInput = document.getElementById("exclude_globs");
10932
10933 // Quick-exclude chips — append pattern to exclude_globs textarea.
10934 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10935 chip.addEventListener("click", function() {
10936 var pattern = chip.getAttribute("data-pattern") || "";
10937 if (!pattern || !excludeGlobsInput) return;
10938 var current = excludeGlobsInput.value.trim();
10939 // For the "skip all" chip, replace any existing dep patterns cleanly.
10940 var patterns = pattern.split("\n");
10941 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10942 var added = false;
10943 patterns.forEach(function(p) {
10944 p = p.trim();
10945 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10946 });
10947 if (added) {
10948 excludeGlobsInput.value = lines.join("\n");
10949 excludeGlobsInput.dispatchEvent(new Event("input"));
10950 }
10951 chip.classList.add("active");
10952 });
10953 });
10954
10955 var liveReportTitle = document.getElementById("live-report-title");
10956 var navProjectPill = document.getElementById("nav-project-pill");
10957 var navProjectTitle = document.getElementById("nav-project-title");
10958 var reportTitlePreview = null;
10959 var wizardProgressFill = document.getElementById("wizard-progress-fill");
10960 var wizardProgressValue = document.getElementById("wizard-progress-value");
10961 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10962 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10963 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10964 var reportTitleTouched = false;
10965 var currentStep = 1;
10966 var previewTimer = null;
10967 var quickScanBtn = document.getElementById("quick-scan-btn");
10968
10969 function dismissAnalysisModal() {
10970 if (loading) loading.classList.remove("active");
10971 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10972 var el = document.getElementById(id);
10973 if (el) el.classList.add("hidden");
10974 });
10975 var cancelBtn = document.getElementById("lc-cancel-btn");
10976 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10977 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10978 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10979 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10980 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10981 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10982 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10983 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10984 }
10985
10986 var lcDismissBtn = document.getElementById("lc-dismiss");
10987 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10988
10989 function startAsyncAnalysis(formData) {
10990 var gitRepo = (formData.get("git_repo") || "").toString();
10991 var gitRef = (formData.get("git_ref") || "").toString();
10992 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10993 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10994
10995 var pathEl = document.getElementById("lc-path");
10996 if (pathEl) pathEl.textContent = displayPath;
10997
10998 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10999 var el = document.getElementById(id);
11000 if (el) el.classList.add("hidden");
11001 });
11002 var cancelBtn = document.getElementById("lc-cancel-btn");
11003 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
11004 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
11005 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
11006 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
11007 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
11008 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
11009
11010 if (loading) loading.classList.add("active");
11011
11012 var startTime = Date.now();
11013 var elapsedTimer = setInterval(function() {
11014 var s = Math.floor((Date.now() - startTime) / 1000);
11015 var el = document.getElementById("lc-elapsed");
11016 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
11017 }, 1000);
11018
11019 var warnShown = false, pollRetries = 0, activeWaitId = null;
11020
11021 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
11022
11023 function lcShowCancelled() {
11024 clearInterval(elapsedTimer);
11025 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
11026 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
11027 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
11028 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
11029 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
11030 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
11031 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
11032 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
11033 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
11034 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
11035 }
11036
11037 var lcCancelBtn = document.getElementById("lc-cancel-btn");
11038 if (lcCancelBtn) {
11039 lcCancelBtn.onclick = function() {
11040 if (!activeWaitId) { dismissAnalysisModal(); return; }
11041 lcCancelBtn.disabled = true;
11042 lcCancelBtn.textContent = "Cancelling…";
11043 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
11044 .then(function() { lcShowCancelled(); })
11045 .catch(function() { lcShowCancelled(); });
11046 };
11047 }
11048
11049 function lcShowError(msg) {
11050 clearInterval(elapsedTimer);
11051 lcSetPhase("Failed");
11052 var msgEl = document.getElementById("lc-err-msg");
11053 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
11054 var errEl = document.getElementById("lc-err");
11055 var actEl = document.getElementById("lc-actions");
11056 if (errEl) errEl.classList.remove("hidden");
11057 if (actEl) actEl.classList.remove("hidden");
11058 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
11059 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
11060 }
11061
11062 function lcPoll(waitId) {
11063 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
11064 .then(function(r) {
11065 if (!r.ok) throw new Error("HTTP " + r.status);
11066 return r.json();
11067 })
11068 .then(function(data) {
11069 pollRetries = 0;
11070 if (data.state === "complete") {
11071 clearInterval(elapsedTimer);
11072 lcSetPhase("Done");
11073 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
11074 } else if (data.state === "failed") {
11075 lcShowError(data.message);
11076 } else if (data.state === "cancelled") {
11077 lcShowCancelled();
11078 } else {
11079 var s = Math.floor((Date.now() - startTime) / 1000);
11080 if (s > 90 && !warnShown) {
11081 warnShown = true;
11082 var w = document.getElementById("lc-warn");
11083 if (w) w.classList.remove("hidden");
11084 }
11085 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
11086 setTimeout(function() { lcPoll(waitId); }, 1500);
11087 }
11088 })
11089 .catch(function() {
11090 pollRetries++;
11091 if (pollRetries >= 5) {
11092 lcShowError("Lost connection to server. Reload to check status.");
11093 } else {
11094 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
11095 }
11096 });
11097 }
11098
11099 var params = new URLSearchParams(formData);
11100 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
11101 .then(function(r) {
11102 var waitId = r.headers.get("x-wait-id");
11103 if (!waitId) { window.location.href = "/scan"; return; }
11104 activeWaitId = waitId;
11105 setTimeout(function() { lcPoll(waitId); }, 1500);
11106 })
11107 .catch(function(err) {
11108 lcShowError("Could not reach server: " + (err.message || err));
11109 });
11110 }
11111
11112 if (quickScanBtn) {
11113 quickScanBtn.addEventListener("click", function () {
11114 var pathVal = pathInput ? pathInput.value.trim() : "";
11115 if (!pathVal) {
11116 alert("Please enter or browse to a project path first.");
11117 return;
11118 }
11119 quickScanBtn.disabled = true;
11120 quickScanBtn.textContent = "Scanning...";
11121 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
11122 startAsyncAnalysis(new FormData(form));
11123 });
11124 }
11125
11126 var mixedPolicyInfo = {
11127 code_only: {
11128 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.",
11129 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'
11130 },
11131 code_and_comment: {
11132 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.",
11133 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'
11134 },
11135 comment_only: {
11136 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.",
11137 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'
11138 },
11139 separate_mixed_category: {
11140 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.",
11141 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'
11142 }
11143 };
11144
11145 var scanPresetInfo = {
11146 balanced: {
11147 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.",
11148 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
11149 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
11150 note: "Best when you want a stable local overview before making deeper adjustments.",
11151 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11152 },
11153 code_focused: {
11154 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
11155 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
11156 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
11157 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
11158 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11159 },
11160 comment_audit: {
11161 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
11162 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
11163 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
11164 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
11165 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11166 },
11167 deep_review: {
11168 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
11169 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
11170 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
11171 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
11172 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
11173 }
11174 };
11175
11176 var artifactPresetInfo = {
11177 review: {
11178 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.",
11179 chips: ["HTML", "PDF"],
11180 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
11181 },
11182 full: {
11183 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.",
11184 chips: ["HTML", "PDF", "JSON"],
11185 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
11186 },
11187 html_only: {
11188 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.",
11189 chips: ["HTML only", "Fast local review"],
11190 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
11191 },
11192 machine: {
11193 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
11194 chips: ["HTML", "JSON"],
11195 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
11196 }
11197 };
11198
11199 function applyTheme(theme) {
11200 if (theme === "dark") document.body.classList.add("dark-theme");
11201 else document.body.classList.remove("dark-theme");
11202 }
11203
11204 function loadSavedTheme() {
11205 var saved = null;
11206 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
11207 applyTheme(saved === "dark" ? "dark" : "light");
11208 }
11209
11210 function updateScrollProgress() {
11211 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
11212 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
11213 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
11214 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
11215 var step = Math.min(Math.max(currentStep, 1), 4);
11216 var base = stepBase[step];
11217 var end = stepEnd[step];
11218
11219 var scrollFrac = 0;
11220 var activePanel = document.querySelector(".wizard-step.active");
11221 if (activePanel) {
11222 var scrollTop = window.scrollY || window.pageYOffset || 0;
11223 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11224 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11225 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11226 var scrolled = scrollTop + viewH - panelTop;
11227 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11228 }
11229
11230 var percent = Math.round(base + (end - base) * scrollFrac);
11231 percent = Math.min(end, Math.max(base, percent));
11232 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11233 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11234 }
11235
11236 function updateWizardProgress() {
11237 updateScrollProgress();
11238 }
11239
11240 var stepDescriptions = [
11241 "Choose a project folder, apply scope filters, and preview which files will be counted.",
11242 "Configure how mixed code-plus-comment lines and docstrings are classified.",
11243 "Pick your output formats, scan preset, and where reports are saved.",
11244 "Review all settings and launch the analysis."
11245 ];
11246
11247 function updateStepNav(step) {
11248 var infoLabel = document.getElementById("step-nav-info-label");
11249 var infoDesc = document.getElementById("step-nav-info-desc");
11250 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11251 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
11252 }
11253
11254 function updateSidebarSummary() {
11255 var sumPath = document.getElementById("sum-path");
11256 var sumPreset = document.getElementById("sum-preset");
11257 var sumOutput = document.getElementById("sum-output");
11258 var sidebarSummary = document.getElementById("sidebar-summary");
11259 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11260 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
11261 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11262 if (sumPath) sumPath.textContent = pathVal || "—";
11263 if (sumPreset) sumPreset.textContent = presetVal || "—";
11264 if (sumOutput) sumOutput.textContent = outputVal || "—";
11265 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11266 }
11267
11268 function setStep(step, pushHistory) {
11269 currentStep = step;
11270 stepPanels.forEach(function (panel) {
11271 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11272 });
11273 stepButtons.forEach(function (button) {
11274 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11275 });
11276 var layoutEl = document.querySelector(".layout");
11277 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11278 updateWizardProgress();
11279 updateStepNav(step);
11280 stepButtons.forEach(function(btn) {
11281 var t = Number(btn.getAttribute("data-step-target"));
11282 btn.classList.toggle("done", t < step);
11283 });
11284 updateSidebarSummary();
11285
11286 if (pushHistory !== false) {
11287 try {
11288 history.pushState({ wizardStep: step }, "", "#step" + step);
11289 } catch (e) {}
11290 }
11291
11292 window.scrollTo({ top: 0, behavior: "instant" });
11293 }
11294
11295 window.addEventListener("popstate", function (e) {
11296 if (e.state && e.state.wizardStep) {
11297 setStep(e.state.wizardStep, false);
11298 } else {
11299 var hashMatch = location.hash.match(/^#step([1-4])$/);
11300 if (hashMatch) setStep(Number(hashMatch[1]), false);
11301 }
11302 });
11303
11304 function inferTitleFromPath(value) {
11305 if (!value) return "project";
11306 var cleaned = value.replace(/[\/\\]+$/, "");
11307 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11308 return parts.length ? parts[parts.length - 1] : value;
11309 }
11310
11311 function updateReportTitleFromPath() {
11312 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11313 if (!reportTitleTouched) {
11314 reportTitleInput.value = inferred;
11315 }
11316 var title = reportTitleInput.value || inferred;
11317 if (liveReportTitle) liveReportTitle.textContent = title;
11318 if (reportTitlePreview) reportTitlePreview.textContent = title;
11319 document.title = "OxideSLOC | " + title;
11320
11321 var projectPath = (pathInput.value || "").trim();
11322 if (navProjectPill && navProjectTitle) {
11323 if (projectPath.length > 0) {
11324 navProjectTitle.textContent = inferred;
11325 navProjectPill.classList.add("visible");
11326 } else {
11327 navProjectTitle.textContent = "";
11328 navProjectPill.classList.remove("visible");
11329 }
11330 }
11331 }
11332
11333 function updateMixedPolicyUI() {
11334 var key = mixedLinePolicy.value || "code_only";
11335 var info = mixedPolicyInfo[key];
11336 document.getElementById("mixed-policy-description").textContent = info.description;
11337 document.getElementById("mixed-policy-example").textContent = info.example;
11338 }
11339
11340 function updatePythonDocstringUI() {
11341 var checked = !!pythonDocstrings.checked;
11342 document.getElementById("python-docstring-example").textContent = checked
11343 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
11344 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
11345 document.getElementById("python-docstring-live-help").textContent = checked
11346 ? "Enabled: docstrings contribute to comment-style totals."
11347 : "Disabled: docstrings are not counted as comment content.";
11348 }
11349
11350 function renderPresetChips(targetId, chips) {
11351 var target = document.getElementById(targetId);
11352 if (!target) return;
11353 target.innerHTML = (chips || []).map(function (chip) {
11354 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11355 }).join('');
11356 }
11357
11358 function updatePresetDescriptions() {
11359 var scanInfo = scanPresetInfo[scanPreset.value];
11360 var artifactInfo = artifactPresetInfo[artifactPreset.value];
11361 document.getElementById("scan-preset-description").textContent = scanInfo.description;
11362 document.getElementById("scan-preset-example").textContent = scanInfo.example;
11363 document.getElementById("scan-preset-note").textContent = scanInfo.note;
11364 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11365 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11366 renderPresetChips("scan-preset-summary", scanInfo.chips);
11367 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11368 }
11369
11370 function applyScanPreset() {
11371 var info = scanPresetInfo[scanPreset.value];
11372 if (!info || !info.apply) return;
11373 mixedLinePolicy.value = info.apply.mixed;
11374 pythonDocstrings.checked = !!info.apply.docstrings;
11375 document.getElementById("generated_file_detection").value = info.apply.generated;
11376 document.getElementById("minified_file_detection").value = info.apply.minified;
11377 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11378 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11379 document.getElementById("binary_file_behavior").value = info.apply.binary;
11380 updateMixedPolicyUI();
11381 updatePythonDocstringUI();
11382 }
11383
11384 function applyArtifactPreset() {
11385 var enabled = { html: false, pdf: false };
11386 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11387 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11388 if (artifactPreset.value === "html_only") { enabled.html = true; }
11389 if (artifactPreset.value === "machine") { enabled.html = true; }
11390
11391 artifactCards.forEach(function (card) {
11392 var artifact = card.getAttribute("data-artifact");
11393 if (artifact === "json") return;
11394 var checked = !!enabled[artifact];
11395 var checkbox = card.querySelector(".artifact-checkbox");
11396 checkbox.checked = checked;
11397 card.classList.toggle("selected", checked);
11398 });
11399 }
11400
11401 function toggleArtifactCard(card) {
11402 var checkbox = card.querySelector(".artifact-checkbox");
11403 checkbox.checked = !checkbox.checked;
11404 card.classList.toggle("selected", checkbox.checked);
11405 }
11406
11407 function updateReview() {
11408 var scanSummary = document.getElementById("review-scan-summary");
11409 var countSummary = document.getElementById("review-count-summary");
11410 var artifactSummary = document.getElementById("review-artifact-summary");
11411 var outputSummary = document.getElementById("review-output-summary");
11412 var previewSummary = document.getElementById("review-preview-summary");
11413 var readinessSummary = document.getElementById("review-readiness-summary");
11414 var includeText = document.getElementById("include_globs").value.trim();
11415 var excludeText = document.getElementById("exclude_globs").value.trim();
11416 var sidePathPreview = document.getElementById("side-path-preview");
11417 var sideOutputPreview = document.getElementById("side-output-preview");
11418 var sideTitlePreview = document.getElementById("side-title-preview");
11419
11420 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11421 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11422 if (sideTitlePreview) {
11423 var rt = document.getElementById("report_title");
11424 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11425 }
11426
11427 scanSummary.innerHTML = ""
11428 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11429 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11430 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11431
11432 countSummary.innerHTML = ""
11433 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11434 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11435 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11436 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11437 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11438 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11439 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11440 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11441
11442 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11443 artifactSummary.innerHTML = ""
11444 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11445 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11446
11447 outputSummary.innerHTML = ""
11448 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11449 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11450
11451 if (previewSummary) {
11452 if (GIT_MODE) {
11453 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>';
11454 } else {
11455 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11456 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11457 var statMap = {};
11458 statButtons.forEach(function (button) {
11459 var valueNode = button.querySelector('.scope-stat-value');
11460 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11461 });
11462 previewSummary.innerHTML = ''
11463 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11464 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11465 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11466 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11467 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11468 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11469
11470 if (readinessSummary) {
11471 var selectedArtifactsCount = selectedArtifacts.length;
11472 readinessSummary.innerHTML = ''
11473 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11474 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11475 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11476 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11477 }
11478 } // end else (non-GIT_MODE)
11479 }
11480 }
11481
11482 function escapeHtml(value) {
11483 return String(value)
11484 .replace(/&/g, "&")
11485 .replace(/</g, "<")
11486 .replace(/>/g, ">")
11487 .replace(/"/g, """)
11488 .replace(/'/g, "'");
11489 }
11490
11491 function isPythonVisible() {
11492 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11493 }
11494
11495 function syncPythonVisibility() {
11496 var html = previewPanel.textContent || "";
11497 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11498 pythonWraps.forEach(function (node) {
11499 node.classList.toggle("hidden", !hasPython);
11500 });
11501 }
11502
11503 function attachPreviewInteractions() {
11504 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11505 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11506 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11507 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11508 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11509 var searchInput = previewPanel.querySelector("#explorer-search");
11510 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11511 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11512 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11513 var activeFilter = "all";
11514 var activeLanguage = "";
11515 var searchTerm = "";
11516 var currentSortKey = null;
11517 var currentSortOrder = "asc";
11518 var childRows = {};
11519
11520 rows.forEach(function (row) {
11521 var parentId = row.getAttribute("data-parent-id") || "";
11522 var rowId = row.getAttribute("data-row-id") || "";
11523 if (!childRows[parentId]) childRows[parentId] = [];
11524 childRows[parentId].push(rowId);
11525 });
11526
11527 function rowById(id) {
11528 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11529 }
11530
11531 function hasCollapsedAncestor(row) {
11532 var parentId = row.getAttribute("data-parent-id");
11533 while (parentId) {
11534 var parent = rowById(parentId);
11535 if (!parent) break;
11536 if (parent.getAttribute("data-expanded") === "false") return true;
11537 parentId = parent.getAttribute("data-parent-id");
11538 }
11539 return false;
11540 }
11541
11542 function updateToggleGlyph(row) {
11543 var toggle = row.querySelector(".tree-toggle");
11544 if (!toggle) return;
11545 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11546 }
11547
11548 function rowSortValue(row, key) {
11549 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11550 }
11551
11552 function updateSortButtons() {
11553 sortButtons.forEach(function (button) {
11554 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11555 var indicator = button.querySelector(".tree-sort-indicator");
11556 button.classList.toggle("active", isActive);
11557 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11558 if (indicator) {
11559 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11560 }
11561 });
11562 }
11563
11564 function sortSiblingRows() {
11565 if (!treeContainer) {
11566 updateSortButtons();
11567 return;
11568 }
11569
11570 var rowMap = {};
11571 var childrenMap = {};
11572 rows.forEach(function (row) {
11573 var rowId = row.getAttribute("data-row-id");
11574 var parentId = row.getAttribute("data-parent-id") || "";
11575 rowMap[rowId] = row;
11576 if (!childrenMap[parentId]) childrenMap[parentId] = [];
11577 childrenMap[parentId].push(rowId);
11578 });
11579
11580 Object.keys(childrenMap).forEach(function (parentId) {
11581 if (!parentId) return;
11582 childrenMap[parentId].sort(function (a, b) {
11583 var rowA = rowMap[a];
11584 var rowB = rowMap[b];
11585 if (!currentSortKey) {
11586 return Number(a) - Number(b);
11587 }
11588 var valueA = rowSortValue(rowA, currentSortKey);
11589 var valueB = rowSortValue(rowB, currentSortKey);
11590 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11591 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11592 var fallbackA = rowSortValue(rowA, "name");
11593 var fallbackB = rowSortValue(rowB, "name");
11594 if (fallbackA < fallbackB) return -1;
11595 if (fallbackA > fallbackB) return 1;
11596 return Number(a) - Number(b);
11597 });
11598 });
11599
11600 var orderedIds = [];
11601 function pushChildren(parentId) {
11602 (childrenMap[parentId] || []).forEach(function (childId) {
11603 orderedIds.push(childId);
11604 pushChildren(childId);
11605 });
11606 }
11607
11608 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11609 orderedIds.push(topId);
11610 pushChildren(topId);
11611 });
11612
11613 orderedIds.forEach(function (id) {
11614 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11615 });
11616 updateSortButtons();
11617 }
11618
11619 function updateLanguageButtons() {
11620 languageButtons.forEach(function (button) {
11621 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11622 var isActive = languageValue === activeLanguage;
11623 button.classList.toggle("active", isActive);
11624 });
11625 }
11626
11627 function rowSelfMatches(row) {
11628 var kind = row.getAttribute("data-kind");
11629 var status = row.getAttribute("data-status");
11630 var language = (row.getAttribute("data-language") || "").toLowerCase();
11631 var name = row.getAttribute("data-name-lower") || "";
11632 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11633 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11634 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11635 var passesLanguage = !activeLanguage || language === activeLanguage;
11636 return passesFilter && passesSearch && passesLanguage;
11637 }
11638
11639 function hasMatchingDescendant(rowId) {
11640 return (childRows[rowId] || []).some(function (childId) {
11641 var childRow = rowById(childId);
11642 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11643 });
11644 }
11645
11646 function rowMatches(row) {
11647 if (rowSelfMatches(row)) return true;
11648 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11649 }
11650
11651 function resetViewState() {
11652 activeFilter = "all";
11653 activeLanguage = "";
11654 searchTerm = "";
11655 currentSortKey = null;
11656 currentSortOrder = "asc";
11657 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11658 if (searchInput) searchInput.value = "";
11659 if (filterSelect) filterSelect.value = "all";
11660 updateLanguageButtons();
11661 }
11662
11663 function applyVisibility() {
11664 rows.forEach(function (row) {
11665 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11666 row.classList.toggle("hidden-by-filter", !visible);
11667 row.style.display = visible ? "grid" : "none";
11668 });
11669 buttons.forEach(function (button) {
11670 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11671 });
11672 if (filterSelect) filterSelect.value = activeFilter;
11673 }
11674
11675 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11676 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11677 var originalStats = {};
11678 buttons.forEach(function (btn) {
11679 var f = btn.getAttribute('data-filter');
11680 var v = btn.querySelector('.scope-stat-value');
11681 if (f && v) originalStats[f] = v.textContent;
11682 });
11683
11684 function applySubmoduleStats(statsJson) {
11685 try {
11686 var s = JSON.parse(statsJson);
11687 buttons.forEach(function (btn) {
11688 var f = btn.getAttribute('data-filter');
11689 var v = btn.querySelector('.scope-stat-value');
11690 if (!v) return;
11691 if (f === 'dir') v.textContent = s.dirs;
11692 else if (f === 'file') v.textContent = s.files;
11693 else if (f === 'supported') v.textContent = s.supported;
11694 else if (f === 'skipped') v.textContent = s.skipped;
11695 else if (f === 'unsupported') v.textContent = s.unsupported;
11696 });
11697 } catch (e) {}
11698 }
11699
11700 function restoreBaseRepoStats() {
11701 buttons.forEach(function (btn) {
11702 var f = btn.getAttribute('data-filter');
11703 var v = btn.querySelector('.scope-stat-value');
11704 if (v && originalStats[f]) v.textContent = originalStats[f];
11705 });
11706 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11707 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11708 }
11709
11710 submoduleChips.forEach(function (chip) {
11711 chip.addEventListener('click', function () {
11712 var statsJson = chip.getAttribute('data-sub-stats');
11713 if (!statsJson) return;
11714 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11715 chip.classList.add('active');
11716 applySubmoduleStats(statsJson);
11717 if (baseRepoBtn) baseRepoBtn.style.display = '';
11718 });
11719 });
11720
11721 if (baseRepoBtn) {
11722 baseRepoBtn.addEventListener('click', function () {
11723 restoreBaseRepoStats();
11724 resetViewState();
11725 sortSiblingRows();
11726 applyVisibility();
11727 });
11728 }
11729
11730 buttons.forEach(function (button) {
11731 button.addEventListener("click", function () {
11732 var filterValue = button.getAttribute("data-filter") || "all";
11733 if (filterValue === "reset-view") {
11734 restoreBaseRepoStats();
11735 resetViewState();
11736 sortSiblingRows();
11737 applyVisibility();
11738 return;
11739 }
11740 activeFilter = filterValue;
11741 applyVisibility();
11742 });
11743 });
11744
11745 rows.forEach(function (row) {
11746 updateToggleGlyph(row);
11747 var toggle = row.querySelector(".tree-toggle");
11748 if (toggle) {
11749 toggle.addEventListener("click", function () {
11750 var expanded = row.getAttribute("data-expanded") !== "false";
11751 row.setAttribute("data-expanded", expanded ? "false" : "true");
11752 updateToggleGlyph(row);
11753 applyVisibility();
11754 });
11755 }
11756 });
11757
11758 actionButtons.forEach(function (button) {
11759 button.addEventListener("click", function () {
11760 var action = button.getAttribute("data-explorer-action");
11761 if (action === "expand-all") {
11762 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11763 } else if (action === "collapse-all") {
11764 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11765 } else if (action === "clear-filters") {
11766 resetViewState();
11767 }
11768 sortSiblingRows();
11769 applyVisibility();
11770 });
11771 });
11772
11773 if (filterSelect) {
11774 filterSelect.addEventListener("change", function () {
11775 activeFilter = filterSelect.value || "all";
11776 applyVisibility();
11777 });
11778 }
11779
11780 languageButtons.forEach(function (button) {
11781 button.addEventListener("click", function () {
11782 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11783 updateLanguageButtons();
11784 applyVisibility();
11785 });
11786 });
11787
11788 sortButtons.forEach(function (button) {
11789 button.addEventListener("click", function () {
11790 var sortKey = button.getAttribute("data-sort-key");
11791 if (currentSortKey === sortKey) {
11792 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11793 } else {
11794 currentSortKey = sortKey;
11795 currentSortOrder = "asc";
11796 }
11797 sortSiblingRows();
11798 applyVisibility();
11799 });
11800 });
11801
11802 if (searchInput) {
11803 searchInput.addEventListener("input", function () {
11804 searchTerm = searchInput.value.trim().toLowerCase();
11805 applyVisibility();
11806 });
11807 }
11808
11809 updateLanguageButtons();
11810 sortSiblingRows();
11811 applyVisibility();
11812 }
11813
11814 function loadPreview() {
11815 if (!previewPanel || !pathInput) return;
11816 if (GIT_MODE) {
11817 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>';
11818 return;
11819 }
11820 var path = pathInput.value.trim();
11821 var zeroWarn = document.getElementById('zero-files-warning');
11822 if (!path) {
11823 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11824 if (zeroWarn) zeroWarn.style.display = 'none';
11825 return;
11826 }
11827 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11828 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11829 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11830 var previewUrl = "/preview?path=" + encodeURIComponent(path)
11831 + "&include_globs=" + encodeURIComponent(includeValue)
11832 + "&exclude_globs=" + encodeURIComponent(excludeValue);
11833 fetch(previewUrl)
11834 .then(function (response) { return response.text(); })
11835 .then(function (html) {
11836 previewPanel.innerHTML = html;
11837 attachPreviewInteractions();
11838 syncPythonVisibility();
11839 updateReview();
11840 setTimeout(collapseLanguagePills, 50);
11841 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11842 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11843 var sizeText = document.getElementById('project-size-text');
11844 var sizeBtn = document.getElementById('project-size-btn');
11845 if (sizeText && projectSize) {
11846 sizeText.textContent = 'Project size: ' + projectSize;
11847 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11848 } else if (sizeText) {
11849 sizeText.textContent = 'Project size: —';
11850 }
11851 if (zeroWarn) {
11852 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11853 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11854 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11855 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11856 if (supportedCount === 0 && fileCount > 0) {
11857 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).';
11858 zeroWarn.style.display = '';
11859 } else {
11860 zeroWarn.style.display = 'none';
11861 }
11862 }
11863 })
11864 .catch(function (err) {
11865 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11866 });
11867 }
11868
11869 function pickDirectory(targetInput, kind) {
11870 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11871 if (browseButton) browseButton.disabled = true;
11872
11873 if (previewPanel && targetInput === pathInput) {
11874 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11875 }
11876
11877 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
11878 .then(function (response) { return response.json(); })
11879 .then(function (data) {
11880 if (data && data.selected_path) {
11881 targetInput.value = data.selected_path;
11882
11883 if (targetInput === pathInput) {
11884 updateReportTitleFromPath();
11885 autoSetOutputDir(data.selected_path);
11886 fetchProjectHistory(data.selected_path);
11887 loadPreview();
11888 suggestCoverageFile(data.selected_path);
11889 }
11890
11891 updateReview();
11892 } else if (targetInput === pathInput) {
11893 // Cancelled — keep existing value and refresh preview with current path
11894 loadPreview();
11895 }
11896 })
11897 .catch(function () {
11898 window.alert("Directory picker request failed.");
11899 if (previewPanel && targetInput === pathInput) {
11900 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11901 }
11902 })
11903 .finally(function () {
11904 if (browseButton) browseButton.disabled = false;
11905 });
11906 }
11907
11908 if (themeToggle) {
11909 themeToggle.addEventListener("click", function () {
11910 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11911 applyTheme(nextTheme);
11912 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11913 });
11914 }
11915
11916 stepButtons.forEach(function (button) {
11917 button.addEventListener("click", function () {
11918 setStep(Number(button.getAttribute("data-step-target")));
11919 });
11920 });
11921
11922 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11923 button.addEventListener("click", function () {
11924 setStep(Number(button.getAttribute("data-step-target")) || 1);
11925 });
11926 });
11927
11928 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11929 button.addEventListener("click", function () {
11930 updateReview();
11931 setStep(Number(button.getAttribute("data-next")));
11932 });
11933 });
11934
11935 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11936 button.addEventListener("click", function () {
11937 setStep(Number(button.getAttribute("data-prev")));
11938 });
11939 });
11940
11941 document.addEventListener("keydown", function (e) {
11942 var tag = (document.activeElement || {}).tagName || "";
11943 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11944 if (e.altKey || e.ctrlKey || e.metaKey) return;
11945 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11946 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11947 });
11948
11949 if (useSamplePath) {
11950 useSamplePath.addEventListener("click", function () {
11951 pathInput.value = "tests/fixtures/basic";
11952 updateReportTitleFromPath();
11953 autoSetOutputDir("tests/fixtures/basic");
11954 loadPreview();
11955 suggestCoverageFile("tests/fixtures/basic");
11956 });
11957 }
11958
11959 if (useDefaultOutput) {
11960 useDefaultOutput.addEventListener("click", function () {
11961 delete outputDirInput.dataset.userEdited;
11962 autoSetOutputDir(pathInput ? pathInput.value : "");
11963 updateReview();
11964 });
11965 }
11966
11967 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11968 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11969 if (browseCoverage) {
11970 browseCoverage.addEventListener("click", function () {
11971 browseCoverage.disabled = true;
11972 var currentVal = coverageInput ? coverageInput.value : "";
11973 fetch("/pick-directory?kind=coverage¤t=" + encodeURIComponent(currentVal))
11974 .then(function (r) { return r.json(); })
11975 .then(function (d) {
11976 if (d && d.selected_path && coverageInput) {
11977 coverageInput.value = d.selected_path;
11978 setCovStatus("idle");
11979 }
11980 })
11981 .catch(function () {})
11982 .finally(function () { browseCoverage.disabled = false; });
11983 });
11984 }
11985
11986 function setCovStatus(state, opts) {
11987 if (!covScanStatus) return;
11988 opts = opts || {};
11989 covScanStatus.className = "cov-scan-status cov-scan-" + state;
11990 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11991 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>';
11992 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>';
11993 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>';
11994 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>';
11995 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11996 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11997 if (state === "scanning") {
11998 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11999 } else if (state === "found") {
12000 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
12001 html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
12002 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
12003 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
12004 } else if (state === "hint") {
12005 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
12006 html += '<div class="cov-scan-title">' + tb2 + ' detected — no coverage file found yet</div>';
12007 html += '<div class="cov-scan-sub">Generate one with:</div>';
12008 html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
12009 } else if (state === "none") {
12010 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
12011 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
12012 }
12013 html += '</div></div>';
12014 covScanStatus.innerHTML = html;
12015 if (state === "found") {
12016 var useBtn = covScanStatus.querySelector(".cov-scan-use");
12017 if (useBtn) useBtn.addEventListener("click", function () {
12018 if (coverageInput) coverageInput.value = "";
12019 covAutoFilled = false;
12020 setCovStatus("idle");
12021 });
12022 }
12023 }
12024
12025 function suggestCoverageFile(projectPath) {
12026 if (!coverageInput || !covScanStatus) return;
12027 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
12028 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
12029 clearTimeout(coverageSuggestTimer);
12030 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
12031 setCovStatus("scanning");
12032 coverageSuggestTimer = setTimeout(function () {
12033 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
12034 .then(function (r) { return r.json(); })
12035 .then(function (d) {
12036 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
12037 if (!d) { setCovStatus("none"); return; }
12038 if (d.found) {
12039 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
12040 setCovStatus("found", { found: d.found, tool: d.tool });
12041 } else if (d.tool && d.hint) {
12042 setCovStatus("hint", { tool: d.tool, hint: d.hint });
12043 } else {
12044 setCovStatus("none");
12045 }
12046 })
12047 .catch(function () { setCovStatus("idle"); });
12048 }, 600);
12049 }
12050
12051 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
12052
12053 if (coverageInput) coverageInput.addEventListener("input", function () {
12054 covAutoFilled = false;
12055 if (!this.value.trim()) setCovStatus("idle");
12056 });
12057
12058 // ── Language pill overflow: collapse to "+N more" chip ─────────────
12059 function collapseLanguagePills() {
12060 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
12061 rows.forEach(function(row) {
12062 // Remove any previous overflow chip
12063 var prev = row.querySelector('.lang-overflow-chip');
12064 if (prev) prev.remove();
12065 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
12066 pills.forEach(function(p) { p.style.display = ''; });
12067 if (!pills.length) return;
12068
12069 // Measure after restoring all pills
12070 var containerRight = row.getBoundingClientRect().right;
12071 var hidden = [];
12072 for (var i = pills.length - 1; i >= 1; i--) {
12073 var rect = pills[i].getBoundingClientRect();
12074 if (rect.right > containerRight + 2) {
12075 hidden.unshift(pills[i]);
12076 pills[i].style.display = 'none';
12077 } else {
12078 break;
12079 }
12080 }
12081
12082 if (hidden.length) {
12083 var chip = document.createElement('button');
12084 chip.type = 'button';
12085 chip.className = 'language-pill lang-overflow-chip';
12086 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
12087 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
12088 row.appendChild(chip);
12089 }
12090 });
12091 }
12092
12093 // Run after preview loads (preview panel populates language pills)
12094 var _origLoadPreviewCb = window.__previewLoaded;
12095 document.addEventListener('previewLoaded', collapseLanguagePills);
12096 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
12097 setTimeout(collapseLanguagePills, 400);
12098
12099 // ── Project history & output dir auto-set ──────────────────────────
12100 var wsOutputRoot = document.getElementById("ws-output-root");
12101 var wsScanCount = document.getElementById("ws-scan-count");
12102 var wsLastScan = document.getElementById("ws-last-scan");
12103 var historyBadge = document.getElementById("path-history-badge");
12104 var historyTimer = null;
12105
12106 var wsOutputLink = document.getElementById("ws-output-link");
12107 function syncStripOutputRoot() {
12108 var val = outputDirInput ? outputDirInput.value : "";
12109 var display = val || "project/sloc";
12110 if (wsOutputRoot) wsOutputRoot.textContent = display;
12111 if (wsOutputLink) wsOutputLink.dataset.folder = val;
12112 }
12113
12114 function autoSetOutputDir(projectPath) {
12115 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
12116 if (GIT_MODE && GIT_OUTPUT_DIR) {
12117 outputDirInput.value = GIT_OUTPUT_DIR;
12118 syncStripOutputRoot();
12119 updateReview();
12120 return;
12121 }
12122 if (!projectPath || !projectPath.trim()) return;
12123 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
12124 outputDirInput.value = cleaned + "/sloc";
12125 syncStripOutputRoot();
12126 updateReview();
12127 }
12128
12129 var wsBranch = document.getElementById("ws-branch");
12130
12131 function fetchProjectHistory(projectPath) {
12132 if (!projectPath || !projectPath.trim()) {
12133 if (wsScanCount) wsScanCount.textContent = "—";
12134 if (wsLastScan) wsLastScan.textContent = "—";
12135 if (wsBranch) wsBranch.textContent = "—";
12136 if (historyBadge) historyBadge.style.display = "none";
12137 return;
12138 }
12139 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
12140 .then(function (r) { return r.ok ? r.json() : null; })
12141 .then(function (data) {
12142 if (!data) return;
12143 var countStr = data.scan_count > 0
12144 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
12145 : "never";
12146 var tsStr = data.last_scan_timestamp
12147 ? data.last_scan_timestamp.replace(" UTC","")
12148 : "—";
12149 if (wsScanCount) wsScanCount.textContent = countStr;
12150 if (wsLastScan) wsLastScan.textContent = tsStr;
12151 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
12152 if (data.scan_count > 0) {
12153 if (historyBadge) {
12154 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
12155 historyBadge.textContent = data.scan_count + " previous scan" +
12156 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
12157 "Last: " + (data.last_scan_timestamp || "—") +
12158 " — " + (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.";
12159 historyBadge.className = "path-history-badge found";
12160 historyBadge.style.display = "";
12161 }
12162 } else {
12163 if (historyBadge) historyBadge.style.display = "none";
12164 }
12165 })
12166 .catch(function () {});
12167 }
12168
12169 function onPathChange() {
12170 var val = pathInput ? pathInput.value : "";
12171 updateReportTitleFromPath();
12172 autoSetOutputDir(val);
12173 updateSidebarSummary();
12174 clearTimeout(historyTimer);
12175 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
12176 if (previewTimer) clearTimeout(previewTimer);
12177 previewTimer = setTimeout(loadPreview, 280);
12178 suggestCoverageFile(val);
12179 }
12180
12181 if (pathInput) {
12182 pathInput.addEventListener("input", onPathChange);
12183 }
12184
12185 if (outputDirInput) {
12186 outputDirInput.addEventListener("input", function () {
12187 outputDirInput.dataset.userEdited = "1";
12188 syncStripOutputRoot();
12189 updateReview();
12190 });
12191 }
12192
12193 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
12194 if (!node) return;
12195 node.addEventListener("input", function () {
12196 updateReview();
12197 if (previewTimer) clearTimeout(previewTimer);
12198 previewTimer = setTimeout(loadPreview, 280);
12199 });
12200 });
12201
12202 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
12203 var node = document.getElementById(id);
12204 if (node) node.addEventListener("change", updateReview);
12205 });
12206
12207 if (reportTitleInput) {
12208 reportTitleInput.addEventListener("input", function () {
12209 reportTitleTouched = reportTitleInput.value.trim().length > 0;
12210 updateReportTitleFromPath();
12211 updateReview();
12212 });
12213 }
12214
12215 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
12216 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
12217 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
12218 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
12219
12220 artifactCards.forEach(function (card) {
12221 card.addEventListener("click", function () {
12222 if (card.classList.contains("artifact-locked")) return;
12223 toggleArtifactCard(card);
12224 updateReview();
12225 });
12226 });
12227
12228 if (coverageInput) {
12229 coverageInput.addEventListener("input", function () {
12230 if (coverageInput.value.trim()) setCovStatus("idle");
12231 });
12232 }
12233
12234 if (form && loading && submitButton) {
12235 form.addEventListener("submit", function (e) {
12236 e.preventDefault();
12237 submitButton.disabled = true;
12238 submitButton.textContent = "Scanning...";
12239 startAsyncAnalysis(new FormData(form));
12240 });
12241 }
12242
12243 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12244 btn.addEventListener('click', function () {
12245 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12246 if (!folder) return;
12247 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12248 });
12249 });
12250
12251 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12252 if (wsOutputLink) {
12253 wsOutputLink.addEventListener('click', function () {
12254 var folder = wsOutputLink.dataset.folder || '';
12255 if (!folder) return;
12256 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12257 });
12258 }
12259
12260 loadSavedTheme();
12261 updateMixedPolicyUI();
12262 updatePythonDocstringUI();
12263 applyScanPreset();
12264 updatePresetDescriptions();
12265 applyArtifactPreset();
12266 updateReview();
12267 updateScrollProgress(); // initialise bar to 0% (step 1)
12268 window.addEventListener("scroll", updateScrollProgress, { passive: true });
12269 onPathChange(); // seed output dir, history badge, and preview from initial path
12270 loadPreview();
12271 updateStepNav(1);
12272
12273 // Restore step from URL hash on initial load (e.g., back-forward cache)
12274 (function() {
12275 var hashMatch = location.hash.match(/^#step([1-4])$/);
12276 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12277 })();
12278
12279 (function randomizeWatermarks() {
12280 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12281 if (!wms.length) return;
12282 var placed = [];
12283 function tooClose(top, left) {
12284 for (var i = 0; i < placed.length; i++) {
12285 var dt = Math.abs(placed[i][0] - top);
12286 var dl = Math.abs(placed[i][1] - left);
12287 if (dt < 16 && dl < 12) return true;
12288 }
12289 return false;
12290 }
12291 function pick(leftBand) {
12292 for (var attempt = 0; attempt < 50; attempt++) {
12293 var top = Math.random() * 88 + 2;
12294 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12295 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12296 }
12297 var top = Math.random() * 88 + 2;
12298 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12299 placed.push([top, left]);
12300 return [top, left];
12301 }
12302 var half = Math.floor(wms.length / 2);
12303 wms.forEach(function (img, i) {
12304 var pos = pick(i < half);
12305 var size = Math.floor(Math.random() * 80 + 110);
12306 var rot = (Math.random() * 360).toFixed(1);
12307 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12308 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;
12309 });
12310 })();
12311
12312 (function spawnCodeParticles() {
12313 var container = document.getElementById('code-particles');
12314 if (!container) return;
12315 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'];
12316 for (var i = 0; i < 38; i++) {
12317 (function(idx) {
12318 var el = document.createElement('span');
12319 el.className = 'code-particle';
12320 el.textContent = snippets[idx % snippets.length];
12321 var left = Math.random() * 94 + 2;
12322 var top = Math.random() * 88 + 6;
12323 var dur = (Math.random() * 10 + 9).toFixed(1);
12324 var delay = (Math.random() * 18).toFixed(1);
12325 var rot = (Math.random() * 26 - 13).toFixed(1);
12326 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12327 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';
12328 container.appendChild(el);
12329 })(i);
12330 }
12331 })();
12332 })();
12333 </script>
12334 <script nonce="{{ csp_nonce }}">
12335 (function () {
12336 var raw = {{ prefill_json|safe }};
12337 if (!raw || typeof raw !== 'object' || !raw.path) return;
12338 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12339 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12340 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12341 setVal('path-input', raw.path || '');
12342 setVal('include-globs', raw.include_globs || '');
12343 setVal('exclude-globs', raw.exclude_globs || '');
12344 setVal('output-dir', raw.output_dir || '');
12345 setVal('report-title', raw.report_title || '');
12346 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12347 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12348 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12349 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12350 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12351 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12352 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12353 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12354 setChecked('generate-html', raw.generate_html !== false);
12355 setChecked('generate-pdf', !!raw.generate_pdf);
12356 // Trigger dynamic UI updates after pre-fill.
12357 setTimeout(function () {
12358 var pathEl = document.getElementById('path-input');
12359 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12360 var policyEl = document.getElementById('mixed-line-policy');
12361 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12362 }, 80);
12363 })();
12364 </script>
12365 <script nonce="{{ csp_nonce }}">
12366 (function(){
12367 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'}];
12368 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);});}
12369 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12370 function init(){
12371 var btn=document.getElementById('settings-btn');if(!btn)return;
12372 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12373 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>';
12374 document.body.appendChild(m);
12375 var g=document.getElementById('scheme-grid');
12376 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);});
12377 var cl=document.getElementById('settings-close');
12378 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);
12379 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');});
12380 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12381 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12382 }
12383 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12384 }());
12385 </script>
12386 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12387 <div class="wb-ftip-arrow"></div>
12388 <span id="wb-ftip-text"></span>
12389 </div>
12390 <script nonce="{{ csp_nonce }}">(function(){
12391 var tip=document.getElementById('wb-ftip');
12392 var txt=document.getElementById('wb-ftip-text');
12393 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12394 if(!tip||!txt)return;
12395 function pos(el){
12396 var r=el.getBoundingClientRect();
12397 tip.style.display='block';
12398 var tw=tip.offsetWidth;
12399 var lx=r.left+r.width/2-tw/2;
12400 if(lx<8)lx=8;
12401 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12402 tip.style.left=lx+'px';
12403 tip.style.top=(r.bottom+8)+'px';
12404 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';}
12405 }
12406 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12407 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12408 el.addEventListener('mouseleave',function(){tip.style.display='none';});
12409 });
12410 })();
12411 (function(){
12412 function fixArtifactHintSpacing(){
12413 var grid=document.querySelector('.artifact-grid');
12414 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12415 }
12416 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12417 }());
12418 </script>
12419 <footer class="site-footer">
12420 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12421 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12422 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12423 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12424 · <a href="/api-docs" rel="noopener">REST API</a>
12425 </footer>
12426</body>
12427</html>
12428"##,
12429 ext = "html"
12430)]
12431struct IndexTemplate {
12432 version: &'static str,
12433 prefill_json: String,
12434 csp_nonce: String,
12435 git_repo: String,
12436 git_ref: String,
12437 git_label_json: String,
12438 git_output_dir_json: String,
12439}
12440
12441#[derive(Template)]
12444#[template(
12445 source = r##"
12446<!doctype html>
12447<html lang="en">
12448<head>
12449 <meta charset="utf-8">
12450 <meta name="viewport" content="width=device-width, initial-scale=1">
12451 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12452 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12453 <style nonce="{{ csp_nonce }}">
12454 :root {
12455 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12456 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12457 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12458 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12459 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12460 }
12461 body.dark-theme {
12462 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12463 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12464 }
12465 *{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);}
12466 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12467 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12468 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12469 .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;}
12470 @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));}}
12471 .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);}
12472 .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12473 .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));}
12474 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12475 .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;}
12476 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12477 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12478 @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; } }
12479 .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;}
12480 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12481 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12482 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12483 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12484 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12485 .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;}
12486 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12487 .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);}
12488 .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;}
12489 .settings-close:hover{color:var(--text);background:var(--surface-2);}
12490 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12491 .settings-modal-body{padding:14px 16px 16px;}
12492 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12493 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12494 .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;}
12495 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12496 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12497 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12498 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12499 .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;}
12500 .tz-select:focus{border-color:var(--oxide);}
12501 .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;}
12502 .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;}
12503 .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12504 .hero{text-align:center;margin:0 auto 18px;}
12505 .hero-logo-wrap{display:inline-block;cursor:default;}
12506 .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;}
12507 .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;}
12508 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12509 .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;}
12510 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%);}
12511 .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;
12512 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12513 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12514 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;}
12515 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12516 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12517 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;}
12518 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:2.5em;opacity:0;}
12519 .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;}
12520 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12521 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12522 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12523 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12524 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12525 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12526 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12527 .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;}
12528 .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;}
12529 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12530 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12531 .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);}
12532 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12533 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12534 .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);}
12535 .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);}
12536 .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);}
12537 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12538 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12539 .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;}
12540 body.dark-theme .action-card-cta{color:var(--oxide);}
12541 .action-card.view .action-card-cta{color:var(--accent-2);}
12542 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12543 .action-card.compare .action-card-cta{color:#7c3aed;}
12544 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12545 .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);}
12546 .action-card.git-tools .action-card-cta{color:#15803d;}
12547 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12548 .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);}
12549 .action-card.trend .action-card-cta{color:#0e7490;}
12550 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12551 .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);}
12552 .action-card.automation .action-card-cta{color:#b45309;}
12553 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12554 .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);}
12555 .action-card.test-metrics .action-card-cta{color:#be185d;}
12556 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12557 .action-card:hover .action-card-cta{gap:12px;}
12558 .action-card.card-split{flex-direction:row;align-items:stretch;}
12559 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12560 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12561 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12562 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12563 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12564 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12565 .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;}
12566 .ac-badge.active{opacity:1;}
12567 .ac-badge.github{border-color:#555;color:#555;}
12568 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12569 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12570 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12571 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12572 body.dark-theme .ac-right-row{color:var(--muted);}
12573 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12574 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12575 .divider{height:1px;background:var(--line);margin:32px 0;}
12576 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12577 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12578 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12579 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12580 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12581 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12582 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12583 body.dark-theme .info-chip-val{color:var(--oxide);}
12584 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12585 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12586 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12587 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12588 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12589 border:6px solid transparent;border-top-color:var(--text);}
12590 .info-chip:hover .info-chip-tip{display:block;}
12591 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12592 .chip-slide.fading{filter:blur(5px);opacity:0;}
12593 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12594 .site-footer a{color:var(--muted);}
12595 .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;}
12596 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12597 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12598 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12599 .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;}
12600 .lan-badge.local{background:var(--oxide-2);}
12601 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12602 .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);}
12603 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12604 .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;}
12605 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12606 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12607 .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;}
12608 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12609 .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;}
12610 .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);}
12611 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12612 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12613 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12614 .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;}
12615 @media (max-height: 1100px) {
12616 .page{padding-top:10px;}
12617 .hero{margin-bottom:10px;}
12618 .hero-logo{width:54px;height:60px;}
12619 .hero-logo-shadow{width:42px;}
12620 .hero-title{font-size:28px;}
12621 .hero-subtitle{font-size:13px;min-height:2em;}
12622 .card-sections{gap:16px;margin-bottom:10px;}
12623 .card-section-grid-2,.card-section-grid-3{gap:10px;}
12624 .action-card{padding:8px 15px 8px;}
12625 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
12626 .action-card-icon svg{width:18px;height:18px;}
12627 .action-card-title{font-size:13px;}
12628 .action-card-desc{font-size:11px;margin-bottom:6px;}
12629 .action-card-cta{font-size:11px;}
12630 .ac-right-row{font-size:11px;}
12631 .divider{margin:14px 0;}
12632 .info-strip{gap:7px;margin-bottom:12px;}
12633 .info-chip{padding:7px 10px;}
12634 .info-chip-val{font-size:13px;}
12635 .info-chip-label{font-size:9px;}
12636 .site-footer{padding:8px 24px;font-size:12px;}
12637 }
12638 @media (max-height: 850px) {
12639 .page{padding-top:6px;}
12640 .hero{margin-bottom:6px;}
12641 .hero-logo{width:42px;height:46px;}
12642 .hero-title{font-size:22px;}
12643 .hero-subtitle{font-size:12px;min-height:1.6em;}
12644 .card-sections{gap:10px;}
12645 .action-card-desc{margin-bottom:4px;}
12646 .divider{margin:8px 0;}
12647 .info-strip{margin-bottom:6px;}
12648 .lan-local-hint{margin-top:10px;}
12649 }
12650 </style>
12651</head>
12652<body>
12653 <div class="background-watermarks" aria-hidden="true">
12654 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12655 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12656 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12657 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12658 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12659 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12660 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12661 </div>
12662 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12663 <div class="top-nav">
12664 <div class="top-nav-inner">
12665 <a class="brand" href="/">
12666 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12667 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12668 </a>
12669 <div class="nav-right">
12670 <a class="nav-pill" href="/">Home</a>
12671 <div class="nav-dropdown">
12672 <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>
12673 <div class="nav-dropdown-menu">
12674 <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>
12675 </div>
12676 </div>
12677 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12678 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12679 <div class="nav-dropdown">
12680 <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>
12681 <div class="nav-dropdown-menu">
12682 <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>
12683 </div>
12684 </div>
12685 <div class="server-status-wrap">
12686 {% if server_mode %}
12687 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12688 <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>
12689 {% else %}
12690 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12691 <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12692 {% endif %}
12693 </div>
12694 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12695 <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>
12696 </button>
12697 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12698 <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>
12699 <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>
12700 </button>
12701 </div>
12702 </div>
12703 </div>
12704
12705 <div class="page">
12706 <div class="hero">
12707 <div class="hero-logo-wrap" id="hero-logo-wrap">
12708 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12709 </div>
12710 <div class="hero-logo-shadow"></div>
12711 <div class="hero-title-wrap">
12712 <div class="hero-title-aura" aria-hidden="true"></div>
12713 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12714 </div>
12715 <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>
12716 </div>
12717
12718 <div class="card-sections">
12719
12720 <div>
12721 <div class="card-section-label">Analysis</div>
12722 <div class="card-section-grid-2">
12723 <a class="action-card scan card-split" href="/scan-setup">
12724 <div class="action-card-left">
12725 <div class="action-card-icon">
12726 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12727 </div>
12728 <div class="action-card-title">Scan Project</div>
12729 <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>
12730 <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>
12731 </div>
12732 <div class="action-card-sep"></div>
12733 <div class="action-card-right">
12734 <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>
12735 <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>
12736 <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>
12737 <div class="ac-right-stat" id="acp-scan-stat"></div>
12738 </div>
12739 </a>
12740 <a class="action-card test-metrics card-split" href="/test-metrics">
12741 <div class="action-card-left">
12742 <div class="action-card-icon">
12743 <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>
12744 </div>
12745 <div class="action-card-title">Test Metrics</div>
12746 <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>
12747 <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>
12748 </div>
12749 <div class="action-card-sep"></div>
12750 <div class="action-card-right">
12751 <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>
12752 <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>
12753 <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>
12754 <div class="ac-right-stat" id="acp-test-stat"></div>
12755 </div>
12756 </a>
12757 </div>
12758 </div>
12759
12760 <div>
12761 <div class="card-section-label">Reports & Insights</div>
12762 <div class="card-section-grid-3">
12763 <a class="action-card view" href="/view-reports">
12764 <div class="action-card-icon">
12765 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12766 </div>
12767 <div class="action-card-title">View Reports</div>
12768 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12769 <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>
12770 </a>
12771 <a class="action-card compare" href="/compare-scans">
12772 <div class="action-card-icon">
12773 <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>
12774 </div>
12775 <div class="action-card-title">Compare Scans</div>
12776 <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>
12777 <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>
12778 </a>
12779 <a class="action-card trend" href="/trend-reports">
12780 <div class="action-card-icon">
12781 <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>
12782 </div>
12783 <div class="action-card-title">Trend Report</div>
12784 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12785 <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>
12786 </a>
12787 </div>
12788 </div>
12789
12790 <div>
12791 <div class="card-section-label">Developer Tools</div>
12792 <div class="card-section-grid-2">
12793 <a class="action-card git-tools card-split" href="/git-browser">
12794 <div class="action-card-left">
12795 <div class="action-card-icon">
12796 <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>
12797 </div>
12798 <div class="action-card-title">Git Browser</div>
12799 <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>
12800 <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>
12801 </div>
12802 <div class="action-card-sep"></div>
12803 <div class="action-card-right">
12804 <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>
12805 <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>
12806 <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>
12807 </div>
12808 </a>
12809 <a class="action-card automation card-split" href="/integrations">
12810 <div class="action-card-left">
12811 <div class="action-card-icon">
12812 <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>
12813 </div>
12814 <div class="action-card-title">Integrations</div>
12815 <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>
12816 <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>
12817 </div>
12818 <div class="action-card-sep"></div>
12819 <div class="action-card-right">
12820 <div class="ac-badges-grid">
12821 <span class="ac-badge github" id="acp-gh">GitHub</span>
12822 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
12823 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
12824 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12825 </div>
12826 <div class="ac-right-stat" id="acp-int-stat"></div>
12827 </div>
12828 </a>
12829 </div>
12830 </div>
12831
12832 </div>
12833
12834 {% if server_mode %}
12835 <div class="lan-card server">
12836 <div class="lan-card-header">
12837 <span class="lan-badge">LAN server</span>
12838 Accessible on your network
12839 </div>
12840 {% if let Some(ip) = lan_ip %}
12841 <div class="lan-url-row">
12842 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12843 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12844 <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>
12845 Copy URL
12846 </button>
12847 </div>
12848 <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12849 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
12850 {% else %}
12851 <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>
12852 {% endif %}
12853 </div>
12854 {% endif %}
12855
12856 <div class="divider"></div>
12857
12858 <div class="info-strip">
12859 <div class="info-chip">
12860 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12861 <div class="chip-slide">
12862 <div class="info-chip-val">41</div>
12863 <div class="info-chip-label">Languages</div>
12864 </div>
12865 </div>
12866 <div class="info-chip">
12867 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12868 <div class="chip-slide">
12869 <div class="info-chip-val">100%</div>
12870 <div class="info-chip-label">Self-contained</div>
12871 </div>
12872 </div>
12873 <div class="info-chip">
12874 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12875 <div class="chip-slide">
12876 <div class="info-chip-val">HTML+PDF</div>
12877 <div class="info-chip-label">Exportable reports</div>
12878 </div>
12879 </div>
12880 <div class="info-chip">
12881 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12882 <div class="chip-slide">
12883 <div class="info-chip-val">Webhook</div>
12884 <div class="info-chip-label">3 platforms</div>
12885 </div>
12886 </div>
12887 <div class="info-chip">
12888 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12889 <div class="chip-slide">
12890 <div class="info-chip-val">IEEE</div>
12891 <div class="info-chip-label">1045-1992</div>
12892 </div>
12893 </div>
12894 </div>
12895
12896 {% if lan_ip.is_none() %}
12897 <div class="lan-local-hint">
12898 <strong>Want teammates on the same network to access this?</strong><br>
12899 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
12900 </div>
12901 {% endif %}
12902 </div>
12903
12904 <footer class="site-footer">
12905 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
12906 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12907 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12908 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12909 · <a href="/api-docs" rel="noopener">REST API</a>
12910 </footer>
12911
12912 <script nonce="{{ csp_nonce }}">
12913 (function () {
12914 var storageKey = 'oxide-sloc-theme';
12915 var body = document.body;
12916 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12917 var toggle = document.getElementById('theme-toggle');
12918 if (toggle) toggle.addEventListener('click', function () {
12919 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12920 body.classList.toggle('dark-theme', next === 'dark');
12921 try { localStorage.setItem(storageKey, next); } catch(e) {}
12922 });
12923 var copyBtn = document.getElementById('lan-copy-btn');
12924 if (copyBtn) copyBtn.addEventListener('click', function() {
12925 var btn = this;
12926 var el = document.getElementById('lan-url-val');
12927 if (!el) return;
12928 var url = el.textContent.trim();
12929 if (navigator.clipboard) {
12930 navigator.clipboard.writeText(url).then(function() {
12931 var orig = btn.innerHTML;
12932 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!';
12933 setTimeout(function() { btn.innerHTML = orig; }, 1800);
12934 });
12935 }
12936 });
12937 (function randomizeWatermarks() {
12938 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12939 if (!wms.length) return;
12940 var placed = [];
12941 function tooClose(top, left) {
12942 for (var i = 0; i < placed.length; i++) {
12943 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12944 if (dt < 16 && dl < 12) return true;
12945 }
12946 return false;
12947 }
12948 function pick(leftBand) {
12949 for (var attempt = 0; attempt < 50; attempt++) {
12950 var top = Math.random() * 88 + 2;
12951 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12952 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12953 }
12954 var top = Math.random() * 88 + 2;
12955 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12956 placed.push([top, left]); return [top, left];
12957 }
12958 var half = Math.floor(wms.length / 2);
12959 wms.forEach(function (img, i) {
12960 var pos = pick(i < half);
12961 var size = Math.floor(Math.random() * 100 + 120);
12962 var rot = (Math.random() * 360).toFixed(1);
12963 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12964 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;
12965 });
12966 })();
12967
12968 (function spawnCodeParticles() {
12969 var container = document.getElementById('code-particles');
12970 if (!container) return;
12971 var snippets = [
12972 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12973 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12974 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12975 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12976 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12977 ];
12978 var count = 38;
12979 for (var i = 0; i < count; i++) {
12980 (function(idx) {
12981 var el = document.createElement('span');
12982 el.className = 'code-particle';
12983 var text = snippets[idx % snippets.length];
12984 el.textContent = text;
12985 var left = Math.random() * 94 + 2;
12986 var top = Math.random() * 88 + 6;
12987 var dur = (Math.random() * 10 + 9).toFixed(1);
12988 var delay = (Math.random() * 18).toFixed(1);
12989 var rot = (Math.random() * 26 - 13).toFixed(1);
12990 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12991 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12992 + '--rot:' + rot + 'deg;--op:' + op + ';'
12993 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12994 container.appendChild(el);
12995 })(i);
12996 }
12997 })();
12998 (function heroAnimations() {
12999 var sub = document.getElementById('hero-subtitle');
13000 if (sub) {
13001 var full = sub.textContent.trim();
13002 sub.textContent = '';
13003 sub.style.opacity = '1';
13004 var cursor = document.createElement('span');
13005 cursor.className = 'hero-cursor';
13006 sub.appendChild(cursor);
13007 var i = 0;
13008 setTimeout(function() {
13009 var iv = setInterval(function() {
13010 if (i < full.length) {
13011 sub.insertBefore(document.createTextNode(full[i]), cursor);
13012 i++;
13013 } else {
13014 clearInterval(iv);
13015 setTimeout(function() {
13016 cursor.style.transition = 'opacity 1s ease';
13017 cursor.style.opacity = '0';
13018 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
13019 }, 2400);
13020 }
13021 }, 11);
13022 }, 374);
13023 }
13024 })();
13025 (function logoBob() {
13026 var logo = document.querySelector('.hero-logo');
13027 var shadow = document.querySelector('.hero-logo-shadow');
13028 if (!logo) return;
13029 var cycleStart = null, cycleDur = 3600;
13030 var peakY = -14, peakScale = 1.07, peakRot = 0;
13031 function newCycle() {
13032 cycleDur = 3000 + Math.random() * 1840;
13033 peakY = -(9 + Math.random() * 13.8);
13034 peakScale = 1.04 + Math.random() * 0.081;
13035 peakRot = (Math.random() * 11.5 - 5.75);
13036 }
13037 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
13038 newCycle();
13039 function frame(ts) {
13040 if (cycleStart === null) cycleStart = ts;
13041 var t = (ts - cycleStart) / cycleDur;
13042 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
13043 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
13044 var y = peakY * phase;
13045 var sc = 1 + (peakScale - 1) * phase;
13046 var rot = peakRot * Math.sin(Math.PI * phase);
13047 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
13048 if (shadow) {
13049 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
13050 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
13051 }
13052 requestAnimationFrame(frame);
13053 }
13054 requestAnimationFrame(frame);
13055 })();
13056 (function mouseEffects() {
13057 var heroTitle = document.getElementById('hero-title');
13058 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
13059 function tick() {
13060 raf = null;
13061 if (heroTitle) {
13062 var r = heroTitle.getBoundingClientRect();
13063 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
13064 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
13065 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
13066 }
13067 }
13068 document.addEventListener('mousemove', function(e) {
13069 mx = e.clientX; my = e.clientY;
13070 if (!raf) raf = requestAnimationFrame(tick);
13071 });
13072 document.addEventListener('mouseleave', function() {
13073 if (heroTitle) {
13074 heroTitle.style.transition = 'transform 0.5s ease';
13075 heroTitle.style.transform = '';
13076 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
13077 }
13078 });
13079 document.querySelectorAll('.action-card').forEach(function(card) {
13080 card.addEventListener('mousemove', function(e) {
13081 var rect = card.getBoundingClientRect();
13082 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
13083 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
13084 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
13085 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
13086 });
13087 card.addEventListener('mouseleave', function() {
13088 card.style.transition = '';
13089 card.style.transform = '';
13090 });
13091 });
13092 })();
13093 (function chipSlideshow() {
13094 var slides = [
13095 [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
13096 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
13097 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
13098 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
13099 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
13100 ];
13101 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
13102 var indices = [0,0,0,0,0];
13103 var paused = [false,false,false,false,false];
13104 chips.forEach(function(chip, i) {
13105 chip.addEventListener('mouseenter', function() { paused[i] = true; });
13106 chip.addEventListener('mouseleave', function() { paused[i] = false; });
13107 });
13108 function advance(i) {
13109 if (paused[i]) return;
13110 var chip = chips[i];
13111 var inner = chip.querySelector('.chip-slide');
13112 if (!inner) return;
13113 inner.classList.add('fading');
13114 setTimeout(function() {
13115 indices[i] = (indices[i] + 1) % slides[i].length;
13116 var s = slides[i][indices[i]];
13117 chip.querySelector('.info-chip-val').textContent = s.v;
13118 chip.querySelector('.info-chip-label').textContent = s.l;
13119 inner.classList.remove('fading');
13120 }, 720);
13121 }
13122 setInterval(function() {
13123 chips.forEach(function(chip, i) { advance(i); });
13124 }, 6000);
13125 })();
13126 (function cardLiveData() {
13127 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
13128 var el = document.getElementById('acp-scan-stat');
13129 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
13130 }).catch(function(){});
13131 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
13132 var el = document.getElementById('acp-test-stat');
13133 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
13134 }).catch(function(){});
13135 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
13136 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
13137 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
13138 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
13139 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
13140 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
13141 var stat = document.getElementById('acp-int-stat');
13142 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
13143 }).catch(function(){});
13144 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
13145 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
13146 }).catch(function(){});
13147 })();
13148 })();
13149 </script>
13150 <script nonce="{{ csp_nonce }}">
13151 (function(){
13152 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'}];
13153 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);});}
13154 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13155 function init(){
13156 var btn=document.getElementById('settings-btn');if(!btn)return;
13157 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13158 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>';
13159 document.body.appendChild(m);
13160 var g=document.getElementById('scheme-grid');
13161 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);});
13162 var cl=document.getElementById('settings-close');
13163 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);
13164 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');});
13165 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13166 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13167 }
13168 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13169 }());
13170 </script>
13171</body>
13172</html>
13173"##,
13174 ext = "html"
13175)]
13176struct SplashTemplate {
13177 csp_nonce: String,
13178 server_mode: bool,
13179 lan_ip: Option<String>,
13180 port: u16,
13181 version: &'static str,
13182}
13183
13184#[derive(Template)]
13187#[template(
13188 source = r##"
13189<!doctype html>
13190<html lang="en">
13191<head>
13192 <meta charset="utf-8">
13193 <meta name="viewport" content="width=device-width, initial-scale=1">
13194 <title>OxideSLOC — Start a Scan</title>
13195 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13196 <style nonce="{{ csp_nonce }}">
13197 :root {
13198 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
13199 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13200 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13201 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13202 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
13203 }
13204 body.dark-theme {
13205 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
13206 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
13207 }
13208 *{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);}
13209 .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);}
13210 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
13211 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
13212 .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));}
13213 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
13214 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
13215 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
13216 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
13217 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13218 @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; } }
13219 .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;}
13220 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
13221 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
13222 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13223 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13224 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13225 .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;}
13226 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13227 .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);}
13228 .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;}
13229 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13230 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13231 .settings-modal-body{padding:14px 16px 16px;}
13232 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13233 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13234 .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;}
13235 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13236 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13237 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13238 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13239 .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;}
13240 .tz-select:focus{border-color:var(--oxide);}
13241 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13242 .page-header{text-align:center;margin-bottom:16px;}
13243 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13244 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13245 /* Cards */
13246 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13247 .option-card-wrap{position:relative;}
13248 .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;}
13249 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13250 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13251 .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;}
13252 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13253 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13254 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13255 .card-top-row{display:flex;align-items:center;gap:20px;}
13256 /* Two-column layout inside each card */
13257 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13258 .card-left{display:flex;align-items:flex-start;min-width:0;}
13259 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13260 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13261 .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);}
13262 .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);}
13263 .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);}
13264 .card-text{min-width:0;}
13265 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13266 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13267 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13268 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13269 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13270 /* Right CTA column */
13271 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13272 .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;}
13273 /* Re-scan count badge */
13274 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13275 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13276 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13277 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13278 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13279 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13280 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13281 body.dark-theme .btn-secondary{color:var(--oxide);}
13282 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13283 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13284 /* File input overlay — must be full-width so it aligns with other card-right buttons */
13285 .file-input-wrap{position:relative;width:100%;}
13286 .file-input-wrap .btn{width:100%;}
13287 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13288 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13289 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13290 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13291 .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;}
13292 @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));}}
13293 /* Recent list (card 3 — full-width section below header) */
13294 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13295 .recent-list{display:flex;flex-direction:column;gap:8px;}
13296 .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;}
13297 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13298 .recent-item-info{flex:1;min-width:0;}
13299 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13300 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13301 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13302 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13303 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13304 .site-footer a{color:var(--muted);}
13305 @media(max-width:680px){
13306 .card-body{grid-template-columns:1fr;}
13307 .card-right{flex-direction:row;flex-wrap:wrap;}
13308 .btn{flex:1;}
13309 }
13310 .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;}
13311 .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;}
13312 .server-online-pill{cursor:default;}
13313 </style>
13314</head>
13315<body>
13316 <div class="background-watermarks" aria-hidden="true">
13317 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13318 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13319 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13320 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13321 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13322 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13323 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13324 </div>
13325 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13326 <div class="top-nav">
13327 <div class="top-nav-inner">
13328 <a class="brand" href="/">
13329 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13330 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13331 </a>
13332 <div class="nav-right">
13333 <a class="nav-pill" href="/">Home</a>
13334 <div class="nav-dropdown">
13335 <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>
13336 <div class="nav-dropdown-menu">
13337 <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>
13338 </div>
13339 </div>
13340 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13341 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13342 <div class="nav-dropdown">
13343 <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>
13344 <div class="nav-dropdown-menu">
13345 <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>
13346 </div>
13347 </div>
13348 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13349 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13350 <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>
13351 </button>
13352 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13353 <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>
13354 <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>
13355 </button>
13356 </div>
13357 </div>
13358 </div>
13359
13360 <div class="page">
13361 <div class="page-header">
13362 <h1>How would you like to scan?</h1>
13363 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13364 </div>
13365
13366 <div class="option-grid">
13367
13368 <!-- Option 1: New scan -->
13369 <div class="option-card-wrap">
13370 <div class="option-card">
13371 <div class="option-icon new-scan">
13372 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13373 </div>
13374 <div class="card-body">
13375 <div class="card-left">
13376 <div class="card-text">
13377 <div class="option-title">Start a new scan</div>
13378 <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>
13379 <ul class="feature-list">
13380 <li>Live project scope preview before you run</li>
13381 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13382 <li>HTML, PDF, and JSON output — your choice</li>
13383 </ul>
13384 </div>
13385 </div>
13386 <div class="card-right">
13387 <a class="btn btn-primary" href="/scan">
13388 Configure & scan
13389 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13390 </a>
13391 <p class="card-tip">Full 4-step setup · all options</p>
13392 </div>
13393 </div>
13394 </div>
13395 </div>
13396
13397 <!-- Option 2: Load from config file -->
13398 <div class="option-card-wrap">
13399 <div class="option-card">
13400 <div class="option-icon load-config">
13401 <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>
13402 </div>
13403 <div class="card-body">
13404 <div class="card-left">
13405 <div class="card-text">
13406 <div class="option-title">Load a saved config</div>
13407 <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>
13408 <ul class="feature-list">
13409 <li>All 15 settings restored from the file</li>
13410 <li>Fully editable — change path or output dir</li>
13411 <li>Works with any scan-config.json</li>
13412 </ul>
13413 </div>
13414 </div>
13415 <div class="card-right">
13416 <div class="file-input-wrap">
13417 <button class="btn btn-secondary" id="load-config-btn" type="button">
13418 <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>
13419 Choose config file
13420 </button>
13421 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13422 </div>
13423 <p class="card-tip" id="config-file-name">Exported after every scan</p>
13424 </div>
13425 </div>
13426 </div>
13427 </div>
13428
13429 <!-- Option 3: Re-scan recent project -->
13430 <div class="option-card-wrap">
13431 <div class="option-card" id="recent-card">
13432 <div class="card-top-row">
13433 <div class="option-icon rescan">
13434 <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>
13435 </div>
13436 <div class="card-body">
13437 <div class="card-left">
13438 <div class="card-text">
13439 <div class="option-title">Re-scan a recent project</div>
13440 <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>
13441 <ul class="feature-list">
13442 <li>All 15+ settings restored from the saved config</li>
13443 <li>Path and output dir are editable before running</li>
13444 <li>Only scans with a saved config appear here</li>
13445 </ul>
13446 </div>
13447 </div>
13448 <div class="card-right">
13449 <div class="rescan-count-box">
13450 <div class="rescan-count-num" id="rescan-count-num">—</div>
13451 <div class="rescan-count-label">saved configs</div>
13452 </div>
13453 <a class="btn btn-secondary" href="/view-reports">
13454 <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>
13455 View all runs
13456 </a>
13457 <p class="card-tip">Opens run history</p>
13458 </div>
13459 </div>
13460 </div>
13461 <div class="section-divider"></div>
13462 <div class="recent-list" id="recent-list">
13463 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13464 </div>
13465 </div>
13466 </div>
13467
13468 </div>
13469 </div>
13470
13471 <footer class="site-footer">
13472 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
13473 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13474 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13475 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13476 · <a href="/api-docs" rel="noopener">REST API</a>
13477 </footer>
13478
13479 <script nonce="{{ csp_nonce }}">
13480 (function () {
13481 var storageKey = 'oxide-sloc-theme';
13482 var body = document.body;
13483 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13484 var toggle = document.getElementById('theme-toggle');
13485 if (toggle) toggle.addEventListener('click', function () {
13486 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13487 body.classList.toggle('dark-theme', next === 'dark');
13488 try { localStorage.setItem(storageKey, next); } catch(e) {}
13489 });
13490
13491 (function randomizeWatermarks() {
13492 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13493 if (!wms.length) return;
13494 var placed = [];
13495 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; }
13496 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]; }
13497 var half = Math.floor(wms.length / 2);
13498 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; });
13499 })();
13500 (function spawnCodeParticles() {
13501 var container = document.getElementById('code-particles');
13502 if (!container) return;
13503 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'];
13504 var count = 38;
13505 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); }
13506 })();
13507 // Recent scans data injected from server
13508 var recentScans = {{ recent_scans_json|safe }};
13509
13510 function configToParams(cfg) {
13511 var p = new URLSearchParams();
13512 p.set('prefilled', '1');
13513 if (cfg.path) p.set('path', cfg.path);
13514 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13515 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13516 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13517 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13518 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13519 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13520 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13521 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13522 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13523 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13524 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13525 if (cfg.report_title) p.set('report_title', cfg.report_title);
13526 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13527 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13528 return p;
13529 }
13530
13531 // Build recent scan list (capped at 3 visible entries)
13532 var list = document.getElementById('recent-list');
13533 var noNote = document.getElementById('no-recent-note');
13534 var hasAny = false;
13535 var MAX_RECENT = 3;
13536 if (Array.isArray(recentScans)) {
13537 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13538 var shown = 0;
13539 validEntries.forEach(function (entry) {
13540 if (shown >= MAX_RECENT) return;
13541 shown++;
13542 hasAny = true;
13543 var item = document.createElement('div');
13544 item.className = 'recent-item';
13545 item.title = 'Restore all settings and open wizard';
13546 item.innerHTML =
13547 '<div class="recent-item-info">' +
13548 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13549 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
13550 '</div>' +
13551 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13552 item.addEventListener('click', function () {
13553 var params = configToParams(entry.config);
13554 window.location.href = '/scan?' + params.toString();
13555 });
13556 list.appendChild(item);
13557 });
13558 if (validEntries.length > MAX_RECENT) {
13559 var moreEl = document.createElement('div');
13560 moreEl.className = 'recent-more-link';
13561 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
13562 list.appendChild(moreEl);
13563 }
13564 }
13565 if (hasAny && noNote) noNote.style.display = 'none';
13566 // Update count badge
13567 var countEl = document.getElementById('rescan-count-num');
13568 if (countEl) {
13569 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13570 countEl.textContent = total > 0 ? total : '0';
13571 }
13572
13573 // Config file loader
13574 var fileInput = document.getElementById('config-file-input');
13575 var fileName = document.getElementById('config-file-name');
13576 if (fileInput) {
13577 fileInput.addEventListener('change', function () {
13578 var file = fileInput.files && fileInput.files[0];
13579 if (!file) return;
13580 if (fileName) fileName.textContent = '✓ ' + file.name;
13581 var reader = new FileReader();
13582 reader.onload = function (e) {
13583 try {
13584 var cfg = JSON.parse(e.target.result);
13585 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13586 var params = configToParams(cfg);
13587 window.location.href = '/scan?' + params.toString();
13588 } catch (err) {
13589 alert('Could not parse config file: ' + err.message);
13590 }
13591 };
13592 reader.readAsText(file);
13593 });
13594 }
13595
13596 function escHtml(s) {
13597 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
13598 }
13599 })();
13600 </script>
13601 <script nonce="{{ csp_nonce }}">
13602 (function(){
13603 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'}];
13604 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);});}
13605 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13606 function init(){
13607 var btn=document.getElementById('settings-btn');if(!btn)return;
13608 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13609 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>';
13610 document.body.appendChild(m);
13611 var g=document.getElementById('scheme-grid');
13612 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);});
13613 var cl=document.getElementById('settings-close');
13614 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);
13615 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');});
13616 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13617 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13618 }
13619 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13620 }());
13621 </script>
13622</body>
13623</html>
13624"##,
13625 ext = "html"
13626)]
13627struct ScanSetupTemplate {
13628 version: &'static str,
13629 recent_scans_json: String,
13630 csp_nonce: String,
13631}
13632
13633#[derive(Template)]
13634#[template(
13635 source = r##"
13636<!doctype html>
13637<html lang="en">
13638<head>
13639 <meta charset="utf-8">
13640 <meta name="viewport" content="width=device-width, initial-scale=1">
13641 <title>OxideSLOC | {{ report_title }} | Report</title>
13642 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13643 <style nonce="{{ csp_nonce }}">
13644 :root {
13645 --radius: 18px;
13646 --bg: #f5efe8;
13647 --surface: rgba(255,255,255,0.82);
13648 --surface-2: #fbf7f2;
13649 --surface-3: #efe6dc;
13650 --line: #e6d0bf;
13651 --line-strong: #dcb89f;
13652 --text: #43342d;
13653 --muted: #7b675b;
13654 --muted-2: #a08777;
13655 --nav: #b85d33;
13656 --nav-2: #7a371b;
13657 --accent: #6f9bff;
13658 --accent-2: #4a78ee;
13659 --oxide: #d37a4c;
13660 --oxide-2: #b35428;
13661 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13662 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13663 --success-bg: #e8f5ed;
13664 --success-text: #1a8f47;
13665 --info-bg: #eef3ff;
13666 --info-text: #4467d8;
13667 }
13668
13669 body.dark-theme {
13670 --bg: #1b1511;
13671 --surface: #261c17;
13672 --surface-2: #2d221d;
13673 --surface-3: #372922;
13674 --line: #524238;
13675 --line-strong: #6c5649;
13676 --text: #f5ece6;
13677 --muted: #c7b7aa;
13678 --muted-2: #aa9485;
13679 --nav: #b85d33;
13680 --nav-2: #7a371b;
13681 --accent: #6f9bff;
13682 --accent-2: #4a78ee;
13683 --oxide: #d37a4c;
13684 --oxide-2: #b35428;
13685 --shadow: 0 18px 42px rgba(0,0,0,0.28);
13686 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13687 --success-bg: #163927;
13688 --success-text: #8fe2a8;
13689 --info-bg: #1c2847;
13690 --info-text: #a9c1ff;
13691 }
13692
13693 * { box-sizing: border-box; }
13694 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); }
13695 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13696 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13697 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13698 .top-nav, .page { position: relative; z-index: 2; }
13699 .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); }
13700 .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; }
13701 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13702 .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)); }
13703 .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; }
13704 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13705 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13706 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13707 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13708 .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; }
13709 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13710 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13711 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13712 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13713 @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; } }
13714 .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; }
13715 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13716 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13717 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13718 .theme-toggle .icon-sun { display:none; }
13719 body.dark-theme .theme-toggle .icon-sun { display:block; }
13720 body.dark-theme .theme-toggle .icon-moon { display:none; }
13721 .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;}
13722 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13723 .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);}
13724 .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;}
13725 .settings-close:hover{color:var(--text);background:var(--surface-2);}
13726 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13727 .settings-modal-body{padding:14px 16px 16px;}
13728 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13729 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13730 .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;}
13731 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13732 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13733 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13734 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13735 .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;}
13736 .tz-select:focus{border-color:var(--oxide);}
13737 .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; }
13738 .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;}
13739 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13740 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13741 .hero, .panel { padding: 22px; }
13742 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13743 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13744 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13745 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13746 .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; }
13747 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13748 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13749 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13750 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13751 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13752 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13753 .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; }
13754 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13755 .delta-card-val { font-size:16px; font-weight:800; }
13756 .delta-card-val.pos { color:#1e7e34; }
13757 .delta-card-val.neg { color:var(--neg); }
13758 .delta-card-val.mod { color:#b35428; }
13759 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13760 .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; }
13761 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13762 .delta-card-inline:hover .delta-card-tip { opacity:1; }
13763 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13764 .compare-ts { font-size:13px; color:var(--muted); }
13765 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13766 .compare-arrow { color: var(--muted); }
13767 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13768 .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; }
13769 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13770 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13771 .button, .copy-button {
13772 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;
13773 }
13774 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13775 @keyframes spin { to { transform: rotate(360deg); } }
13776 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13777 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13778 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13779 .path-item strong { display: block; margin-bottom: 6px; }
13780 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13781 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13782 .path-subitem { flex: 1; }
13783 .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); }
13784 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); }
13785 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13786 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13787 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13788 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13789 th { color: var(--muted); font-weight: 700; }
13790 tr:last-child td { border-bottom: none; }
13791 #subm-tbl col:nth-child(1){width:15%;}
13792 #subm-tbl col:nth-child(2){width:31%;}
13793 #subm-tbl col:nth-child(3){width:9%;}
13794 #subm-tbl col:nth-child(4){width:9%;}
13795 #subm-tbl col:nth-child(5){width:9%;}
13796 #subm-tbl col:nth-child(6){width:9%;}
13797 #subm-tbl col:nth-child(7){width:9%;}
13798 #subm-tbl col:nth-child(8){width:9%;}
13799 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13800 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13801 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13802 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13803 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13804 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13805 .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; }
13806 .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; }
13807 .soft-chip.success svg { flex:0 0 auto; }
13808 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); }
13809 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13810 .muted { color: var(--muted); }
13811 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13812 .site-footer a{color:var(--muted);}
13813 .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; }
13814 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13815 .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; }
13816 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13817 /* Stat chips (matches HTML report) */
13818 .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13819 @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13820 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13821 .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; }
13822 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13823 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13824 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13825 .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; }
13826 .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; }
13827 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13828 .stat-chip:hover .stat-chip-tip { opacity:1; }
13829 /* Submodule panel */
13830 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13831 /* Metrics tables stack */
13832 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13833 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13834 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13835 .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)); }
13836 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13837 /* Metrics table */
13838 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13839 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13840 .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; }
13841 .metrics-table thead th:not(:first-child) { text-align: right; }
13842 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13843 .metrics-table tbody tr:last-child td { border-bottom: none; }
13844 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13845 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13846 .metrics-table tbody tr:hover td { background: var(--surface-2); }
13847 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13848 .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; }
13849 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13850 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13851 .mt-val-pos { color: var(--pos); font-weight: 700; }
13852 .mt-val-neg { color: var(--neg); font-weight: 700; }
13853 .mt-val-zero { color: var(--muted); }
13854 .mt-val-mod { color: var(--oxide-2); }
13855 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13856 @media (max-width: 1180px) {
13857 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13858 .nav-project-slot, .nav-status { justify-content:flex-start; }
13859 .hero-top { flex-direction: column; }
13860 }
13861 .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;}
13862 @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));}}
13863 .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;}
13864 /* ── Result-page chart controls ─────────────────────────────────────────── */
13865 .r-chart-section{margin-bottom:24px;}
13866 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13867 .section-pair > .panel{flex-shrink:0;}
13868 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13869 .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;}
13870 .r-chart-select:focus{border-color:var(--accent);}
13871 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13872 .r-chart-container svg{display:block;width:100%;height:auto;}
13873 .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13874 .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13875 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13876 .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;}
13877 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13878 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13879 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13880 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13881 #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;}
13882 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13883 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13884 .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;}
13885 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13886 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13887 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13888 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13889 .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%;}
13890 .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%;}
13891 body.has-report-banner .top-nav{top:27px;}
13892 body.has-report-banner{padding-bottom:27px;}
13893 </style>
13894</head>
13895<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13896 <div class="background-watermarks" aria-hidden="true">
13897 <img src="/images/logo/logo-text.png" alt="" />
13898 <img src="/images/logo/logo-text.png" alt="" />
13899 <img src="/images/logo/logo-text.png" alt="" />
13900 <img src="/images/logo/logo-text.png" alt="" />
13901 <img src="/images/logo/logo-text.png" alt="" />
13902 <img src="/images/logo/logo-text.png" alt="" />
13903 <img src="/images/logo/logo-text.png" alt="" />
13904 <img src="/images/logo/logo-text.png" alt="" />
13905 <img src="/images/logo/logo-text.png" alt="" />
13906 <img src="/images/logo/logo-text.png" alt="" />
13907 <img src="/images/logo/logo-text.png" alt="" />
13908 <img src="/images/logo/logo-text.png" alt="" />
13909 <img src="/images/logo/logo-text.png" alt="" />
13910 <img src="/images/logo/logo-text.png" alt="" />
13911 </div>
13912 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13913 {% if let Some(banner) = report_header_footer %}
13914 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13915 {% endif %}
13916 <div class="top-nav">
13917 <div class="top-nav-inner">
13918 <a class="brand" href="/">
13919 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13920 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13921 </a>
13922 <div class="nav-project-slot">
13923 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13924 </div>
13925 <div class="nav-status">
13926 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13927 <div class="nav-dropdown">
13928 <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>
13929 <div class="nav-dropdown-menu">
13930 <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>
13931 </div>
13932 </div>
13933 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13934 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13935 <div class="nav-dropdown">
13936 <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>
13937 <div class="nav-dropdown-menu">
13938 <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>
13939 </div>
13940 </div>
13941 <div class="server-status-wrap">
13942 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13943 <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>
13944 </div>
13945 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13946 <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>
13947 </button>
13948 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13949 <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>
13950 <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>
13951 </button>
13952 </div>
13953 </div>
13954 </div>
13955
13956 <div class="page">
13957 <section class="hero">
13958 <div class="hero-top">
13959 <div>
13960 <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>
13961 <h1 class="hero-title">{{ report_title }}</h1>
13962 <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>
13963 </div>
13964 <div class="hero-quick-actions">
13965 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13966 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13967 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13968 </div>
13969 </div>
13970
13971 <div class="summary-strip">
13972 <div class="stat-chip" data-raw="{{ physical_lines }}">
13973 <div class="stat-chip-label">Physical Lines</div>
13974 <div class="stat-chip-val">{{ physical_lines }}</div>
13975 <div class="stat-chip-exact"></div>
13976 <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13977 </div>
13978 <div class="stat-chip" data-raw="{{ code_lines }}">
13979 <div class="stat-chip-label">Code</div>
13980 <div class="stat-chip-val">{{ code_lines }}</div>
13981 <div class="stat-chip-exact"></div>
13982 <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13983 </div>
13984 <div class="stat-chip" data-raw="{{ comment_lines }}">
13985 <div class="stat-chip-label">Comments</div>
13986 <div class="stat-chip-val">{{ comment_lines }}</div>
13987 <div class="stat-chip-exact"></div>
13988 <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13989 </div>
13990 <div class="stat-chip" data-raw="{{ blank_lines }}">
13991 <div class="stat-chip-label">Blank</div>
13992 <div class="stat-chip-val">{{ blank_lines }}</div>
13993 <div class="stat-chip-exact"></div>
13994 <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13995 </div>
13996 <div class="stat-chip" data-raw="{{ files_analyzed }}">
13997 <div class="stat-chip-label">Files Analyzed</div>
13998 <div class="stat-chip-val">{{ files_analyzed }}</div>
13999 <div class="stat-chip-exact"></div>
14000 <div class="stat-chip-tip">Source files successfully parsed and counted</div>
14001 </div>
14002 <div class="stat-chip" data-raw="{{ functions }}">
14003 <div class="stat-chip-label">Functions</div>
14004 <div class="stat-chip-val">{{ functions }}</div>
14005 <div class="stat-chip-exact"></div>
14006 <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
14007 </div>
14008 </div>
14009
14010 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
14011 <div class="compare-banner">
14012 <div class="compare-banner-body">
14013 <div class="compare-banner-meta">
14014 <span class="compare-label">Previous scan</span>
14015 <span class="compare-ts">{{ prev_ts }}</span>
14016 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
14017 {% if let Some(prev_code) = prev_run_code_lines %}
14018 <div class="compare-banner-stats" style="margin-top:4px;">
14019 <span>Code before: <strong>{{ prev_code }}</strong></span>
14020 <span class="compare-arrow">→</span>
14021 <span>Code now: <strong>{{ code_lines }}</strong></span>
14022 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
14023 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
14024 </div>
14025 {% endif %}
14026 </div>
14027 {% if delta_lines_added.is_some() %}
14028 <div class="delta-cards-inline">
14029 <div class="delta-card-inline">
14030 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
14031 <div class="delta-card-lbl">lines added</div>
14032 <div class="delta-card-tip">Code lines added since the previous scan</div>
14033 </div>
14034 <div class="delta-card-inline">
14035 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
14036 <div class="delta-card-lbl">lines removed</div>
14037 <div class="delta-card-tip">Code lines removed since the previous scan</div>
14038 </div>
14039 <div class="delta-card-inline">
14040 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
14041 <div class="delta-card-lbl">unmodified lines</div>
14042 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
14043 </div>
14044 <div class="delta-card-inline">
14045 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
14046 <div class="delta-card-lbl">files modified</div>
14047 <div class="delta-card-tip">Files with at least one line changed</div>
14048 </div>
14049 <div class="delta-card-inline">
14050 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
14051 <div class="delta-card-lbl">files added</div>
14052 <div class="delta-card-tip">New files added since the previous scan</div>
14053 </div>
14054 <div class="delta-card-inline">
14055 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
14056 <div class="delta-card-lbl">files removed</div>
14057 <div class="delta-card-tip">Files deleted since the previous scan</div>
14058 </div>
14059 <div class="delta-card-inline">
14060 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
14061 <div class="delta-card-lbl">files unchanged</div>
14062 <div class="delta-card-tip">Files with no changes since the previous scan</div>
14063 </div>
14064 </div>
14065 {% else %}
14066 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
14067 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
14068 </p>
14069 {% endif %}
14070 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
14071 </div>
14072 </div>
14073 {% endif %}{% endif %}
14074
14075 <div class="action-grid">
14076 <div class="action-card">
14077 <h3>HTML report</h3>
14078 <div class="action-buttons">
14079 {% match html_url %}
14080 {% when Some with (url) %}
14081 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
14082 {% when None %}{% endmatch %}
14083 {% match html_download_url %}
14084 {% when Some with (url) %}
14085 <a class="button secondary" href="{{ url }}">Download HTML</a>
14086 {% when None %}{% endmatch %}
14087 {% match html_path %}
14088 {% when Some with (_path) %}{% when None %}{% endmatch %}
14089 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
14090 </div>
14091 </div>
14092 <div class="action-card">
14093 <h3>PDF report</h3>
14094 <div class="action-buttons">
14095 {% match pdf_url %}
14096 {% when Some with (url) %}
14097 {% if pdf_generating %}
14098 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
14099 <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>
14100 Generating PDF…
14101 </button>
14102 {% else %}
14103 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
14104 {% endif %}
14105 {% when None %}{% endmatch %}
14106 {% match pdf_download_url %}
14107 {% when Some with (url) %}
14108 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
14109 {% when None %}{% endmatch %}
14110 {% match pdf_path %}
14111 {% when Some with (_path) %}{% when None %}{% endmatch %}
14112 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
14113 </div>
14114 </div>
14115 <div class="action-card">
14116 <h3>JSON result</h3>
14117 <div class="action-buttons">
14118 {% match json_url %}
14119 {% when Some with (url) %}
14120 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
14121 {% when None %}{% endmatch %}
14122 {% match json_download_url %}
14123 {% when Some with (url) %}
14124 <a class="button secondary" href="{{ url }}">Download JSON</a>
14125 {% when None %}{% endmatch %}
14126 {% match json_path %}
14127 {% when Some with (_path) %}
14128 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
14129 {% when None %}
14130 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
14131 {% endmatch %}
14132 </div>
14133 </div>
14134 <div class="action-card">
14135 <h3>Scan config</h3>
14136 <div class="action-buttons">
14137 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
14138 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
14139 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
14140 </div>
14141 </div>
14142 {% if confluence_configured %}
14143 <div class="action-card" id="confluenceCard">
14144 <h3>Confluence</h3>
14145 <div class="action-buttons">
14146 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
14147 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
14148 </div>
14149 <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>
14150 </div>
14151 {% endif %}
14152 </div>
14153 {% if confluence_configured %}
14154 <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;">
14155 <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);">
14156 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
14157 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
14158 <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;">
14159 <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>
14160 <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;">
14161 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
14162 <div style="display:flex;gap:10px;justify-content:flex-end;">
14163 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
14164 <button class="button" id="confSubmitBtn" type="button">Post</button>
14165 </div>
14166 </div>
14167 </div>
14168 {% endif %}
14169 {% if !submodule_rows.is_empty() %}
14170 <div class="submodule-panel">
14171 <div class="toolbar-row">
14172 <div>
14173 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
14174 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
14175 </div>
14176 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
14177 </div>
14178 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
14179 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
14180 <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>
14181 <thead>
14182 <tr>
14183 <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>
14184 <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>
14185 <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>
14186 <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>
14187 <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>
14188 <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>
14189 <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>
14190 <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>
14191 </tr>
14192 </thead>
14193 <tbody>
14194 {% for row in submodule_rows %}
14195 <tr>
14196 <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>
14197 <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>
14198 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
14199 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
14200 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
14201 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
14202 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
14203 <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>
14204 </tr>
14205 {% endfor %}
14206 </tbody>
14207 </table>
14208 </div>
14209 </div>
14210 {% endif %}
14211
14212 <div class="metrics-tables-stack">
14213
14214 <div class="metrics-table-wrap">
14215 <div class="metrics-table-title">Files</div>
14216 <table class="metrics-table">
14217 <thead>
14218 <tr>
14219 <th>Metric</th>
14220 <th>This Run</th>
14221 <th>Previous</th>
14222 <th>Change</th>
14223 </tr>
14224 </thead>
14225 <tbody>
14226 <tr>
14227 <td>Files analyzed</td>
14228 <td class="mt-val-large">{{ files_analyzed }}</td>
14229 <td>{{ prev_fa_str }}</td>
14230 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14231 </tr>
14232 <tr>
14233 <td>Files skipped</td>
14234 <td>{{ files_skipped }}</td>
14235 <td>{{ prev_fs_str }}</td>
14236 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14237 </tr>
14238 <tr>
14239 <td>Files modified</td>
14240 <td class="mt-val-na">—</td>
14241 <td class="mt-val-na">—</td>
14242 <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>
14243 </tr>
14244 <tr>
14245 <td>Files unchanged</td>
14246 <td class="mt-val-na">—</td>
14247 <td class="mt-val-na">—</td>
14248 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14249 </tr>
14250 </tbody>
14251 </table>
14252 </div>
14253
14254 <div class="metrics-table-wrap">
14255 <div class="metrics-table-title">Line Counts</div>
14256 <table class="metrics-table">
14257 <thead>
14258 <tr>
14259 <th>Metric</th>
14260 <th>This Run</th>
14261 <th>Previous</th>
14262 <th>Change</th>
14263 </tr>
14264 </thead>
14265 <tbody>
14266 <tr>
14267 <td>Physical lines</td>
14268 <td class="mt-val-large">{{ physical_lines }}</td>
14269 <td>{{ prev_pl_str }}</td>
14270 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14271 </tr>
14272 <tr>
14273 <td>Code lines</td>
14274 <td class="mt-val-large">{{ code_lines }}</td>
14275 <td>{{ prev_cl_str }}</td>
14276 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14277 </tr>
14278 <tr>
14279 <td>Comment lines</td>
14280 <td>{{ comment_lines }}</td>
14281 <td>{{ prev_cml_str }}</td>
14282 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14283 </tr>
14284 <tr>
14285 <td>Blank lines</td>
14286 <td>{{ blank_lines }}</td>
14287 <td>{{ prev_bl_str }}</td>
14288 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14289 </tr>
14290 <tr>
14291 <td>Mixed (separate)</td>
14292 <td>{{ mixed_lines }}</td>
14293 <td class="mt-val-na">—</td>
14294 <td class="mt-val-na">—</td>
14295 </tr>
14296 </tbody>
14297 </table>
14298 </div>
14299
14300 <div class="metrics-tables-lower">
14301 <div class="metrics-table-wrap">
14302 <div class="metrics-table-title">Code Structure</div>
14303 <table class="metrics-table">
14304 <thead>
14305 <tr>
14306 <th>Metric</th>
14307 <th>This Run</th>
14308 </tr>
14309 </thead>
14310 <tbody>
14311 <tr>
14312 <td>Functions</td>
14313 <td>{{ functions }}</td>
14314 </tr>
14315 <tr>
14316 <td>Classes / Types</td>
14317 <td>{{ classes }}</td>
14318 </tr>
14319 <tr>
14320 <td>Variables</td>
14321 <td>{{ variables }}</td>
14322 </tr>
14323 <tr>
14324 <td>Imports</td>
14325 <td>{{ imports }}</td>
14326 </tr>
14327 </tbody>
14328 </table>
14329 </div>
14330
14331 <div class="metrics-table-wrap">
14332 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14333 <table class="metrics-table">
14334 <thead>
14335 <tr>
14336 <th>Metric</th>
14337 <th>Change</th>
14338 </tr>
14339 </thead>
14340 <tbody>
14341 <tr>
14342 <td>Lines added</td>
14343 <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>
14344 </tr>
14345 <tr>
14346 <td>Lines removed</td>
14347 <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>
14348 </tr>
14349 <tr>
14350 <td>Lines modified (net)</td>
14351 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14352 </tr>
14353 <tr>
14354 <td>Lines unmodified</td>
14355 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14356 </tr>
14357 </tbody>
14358 </table>
14359 </div>
14360 </div>
14361
14362 </div>
14363
14364 <div class="path-list">
14365 <div class="path-item">
14366 <div class="path-item-label">Project path</div>
14367 <code>{{ project_path }}</code>
14368 </div>
14369 <div class="path-item">
14370 <div class="path-item-label">Git branch</div>
14371 {% if let Some(branch) = git_branch %}
14372 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14373 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14374 {% else %}
14375 <code style="color:var(--muted)">—</code>
14376 {% endif %}
14377 </div>
14378 <div class="path-item">
14379 <div class="path-item-label">Output folder</div>
14380 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14381 </div>
14382 <div class="path-item">
14383 <div class="path-item-label">Run ID</div>
14384 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14385 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14386 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14387 </div>
14388 </div>
14389 </div>
14390 </section>
14391
14392 <div id="r-tt" aria-hidden="true"></div>
14393
14394 <div class="section-pair">
14395 <section class="panel">
14396 <div class="toolbar-row">
14397 <div>
14398 <h2>Language breakdown</h2>
14399 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14400 </div>
14401 </div>
14402 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14403 </section>
14404
14405 <section class="panel r-chart-section">
14406 <div class="toolbar-row" style="margin-bottom:16px;">
14407 <div>
14408 <h2>Visualizations</h2>
14409 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14410 </div>
14411 </div>
14412
14413 <div class="r-viz-grid">
14414 <div class="r-viz-card">
14415 <p class="r-viz-card-title">Language Composition</p>
14416 <div class="r-chart-tab-bar">
14417 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14418 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14419 </div>
14420 <div class="r-chart-container" id="r-composition-chart"></div>
14421 </div>
14422 <div class="r-viz-card">
14423 <p class="r-viz-card-title">Files vs Code Lines</p>
14424 <div class="r-chart-container" id="r-scatter-chart"></div>
14425 </div>
14426 {% if has_semantic_data %}
14427 <div class="r-viz-card">
14428 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14429 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14430 <select class="r-chart-select" id="r-semantic-metric">
14431 <option value="functions">Functions</option>
14432 <option value="classes">Classes</option>
14433 <option value="variables">Variables</option>
14434 <option value="imports">Imports</option>
14435 </select>
14436 </div>
14437 <div class="r-chart-container" id="r-semantic-chart"></div>
14438 </div>
14439 {% endif %}
14440 {% if has_submodule_data %}
14441 <div class="r-viz-card">
14442 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14443 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14444 <select class="r-chart-select" id="r-sub-metric">
14445 <option value="code">Code Lines</option>
14446 <option value="comment">Comments</option>
14447 <option value="blank">Blank Lines</option>
14448 <option value="physical">Physical Lines</option>
14449 <option value="files">Files</option>
14450 </select>
14451 <select class="r-chart-select" id="r-sub-sort">
14452 <option value="desc">Value ↓</option>
14453 <option value="asc">Value ↑</option>
14454 <option value="name">Name A→Z</option>
14455 </select>
14456 </div>
14457 <div class="r-chart-container" id="r-submodule-chart"></div>
14458 </div>
14459 {% endif %}
14460 </div>
14461
14462 </section>
14463 </div>
14464
14465 </div>
14466
14467 <script nonce="{{ csp_nonce }}">
14468 (function () {
14469 var body = document.body;
14470 var themeToggle = document.getElementById('theme-toggle');
14471 var storageKey = 'oxide-sloc-theme';
14472
14473 function applyTheme(theme) {
14474 body.classList.toggle('dark-theme', theme === 'dark');
14475 }
14476
14477 function loadSavedTheme() {
14478 try {
14479 var saved = localStorage.getItem(storageKey);
14480 if (saved === 'dark' || saved === 'light') {
14481 applyTheme(saved);
14482 }
14483 } catch (e) {}
14484 }
14485
14486 if (themeToggle) {
14487 themeToggle.addEventListener('click', function () {
14488 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14489 applyTheme(nextTheme);
14490 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14491 });
14492 }
14493
14494 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14495 button.addEventListener('click', function () {
14496 var value = button.getAttribute('data-copy-value') || '';
14497 if (!value) return;
14498 if (navigator.clipboard && navigator.clipboard.writeText) {
14499 navigator.clipboard.writeText(value).catch(function () {});
14500 }
14501 });
14502 });
14503
14504 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14505 btn.addEventListener('click', function () {
14506 var folder = btn.getAttribute('data-folder') || '';
14507 if (!folder) return;
14508 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14509 });
14510 });
14511
14512 loadSavedTheme();
14513
14514 // ── Compact number formatting for stat chips ──────────────────────────
14515 (function(){
14516 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();}
14517 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14518 var raw=parseInt(chip.getAttribute('data-raw'),10);
14519 if(isNaN(raw))return;
14520 var valEl=chip.querySelector('.stat-chip-val');
14521 if(valEl)valEl.textContent=fmt(raw);
14522 var exactEl=chip.querySelector('.stat-chip-exact');
14523 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14524 });
14525 })();
14526
14527 // ── Shared tooltip for all result-page charts ─────────────────────────
14528 var rTT=(function(){
14529 var el=document.getElementById('r-tt');
14530 if(!el)return{s:function(){},h:function(){},m:function(){}};
14531 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14532 function hide(){el.style.display='none';}
14533 function move(e){
14534 var x=e.clientX+16,y=e.clientY-12;
14535 var r=el.getBoundingClientRect();
14536 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14537 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14538 el.style.left=x+'px';el.style.top=y+'px';
14539 }
14540 return{s:show,h:hide,m:move};
14541 })();
14542 window.rTT=rTT;
14543
14544 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14545 (function(){
14546 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14547 document.addEventListener('mouseover',function(e){
14548 var t=e.target;
14549 while(t&&t.getAttribute){
14550 var l=t.getAttribute('data-ttl');
14551 if(l!==null){
14552 var v=t.getAttribute('data-ttv')||'';
14553 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14554 return;
14555 }
14556 t=t.parentNode;
14557 }
14558 });
14559 document.addEventListener('mouseout',function(e){
14560 var t=e.target;
14561 while(t&&t.getAttribute){
14562 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14563 t=t.parentNode;
14564 }
14565 });
14566 document.addEventListener('mousemove',function(e){
14567 var el=document.getElementById('r-tt');
14568 if(el&&el.style.display!=='none')rTT.m(e);
14569 });
14570 })();
14571
14572 // ── Language overview charts ───────────────────────────────────────────
14573 (function(){
14574 var D={{ lang_chart_json|safe }};
14575 if(!D||!D.length)return;
14576 var el=document.getElementById('result-lang-charts');
14577 if(!el)return;
14578 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14579 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14580 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14581 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();}
14582 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14583 function px(n){return Math.round(n);}
14584 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+'"';}
14585 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14586
14587 // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14588 var cx=100,cy=110,Ro=88,Ri=48;
14589 var legX=204,DW=360,DH=220;
14590 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">';
14591 if(D.length===1){
14592 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14593 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+'"/>';
14594 } else {
14595 var ang=-Math.PI/2;
14596 D.forEach(function(d,i){
14597 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14598 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14599 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14600 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14601 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14602 var pct=Math.round(d.code/tot*100);
14603 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"/>';
14604 ang+=sw;
14605 });
14606 }
14607 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14608 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14609 var legRows=Math.min(D.length,8);
14610 var legYStart=Math.round((DH-legRows*22)/2);
14611 D.forEach(function(d,i){
14612 if(i>=8)return;
14613 var ly=legYStart+i*22;
14614 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14615 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14616 });
14617 ds+='</svg>';
14618
14619 // Horizontal stacked-bar chart — fills container width
14620 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14621 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14622 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">';
14623 D.forEach(function(d,i){
14624 var y=6+i*rHb,x=LW;
14625 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14626 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>';
14627 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;
14628 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;
14629 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"/>';
14630 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>';
14631 });
14632 var ly=SH-14;
14633 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>';
14634 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>';
14635 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>';
14636 bs+='</svg>';
14637 el.innerHTML='<div class="r-lang-overview">'+
14638 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14639 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14640 '</div>';
14641 })();
14642
14643 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14644 (function(){
14645 var LANG_D={{ lang_chart_json|safe }};
14646 var SCAT_D={{ scatter_chart_json|safe }};
14647 var SEM_D={{ semantic_chart_json|safe }};
14648 var SUB_D={{ submodule_chart_json|safe }};
14649 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14650 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14651 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();}
14652 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
14653 function px(n){return Math.round(n);}
14654 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+'"';}
14655
14656 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14657 function renderComposition(mode){
14658 var el=document.getElementById('r-composition-chart');
14659 if(!el||!LANG_D||!LANG_D.length)return;
14660 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14661 var LW=110,SH=224;
14662 var svgW=Math.max(320,el.offsetWidth||480);
14663 var BW=Math.max(120,svgW-LW-80);
14664 var legendH=24,topPad=4;
14665 var n=LANG_D.length||1;
14666 var rowTotal=Math.floor((SH-legendH-topPad)/n);
14667 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14668 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">';
14669 if(mode==='pct'){
14670 LANG_D.forEach(function(d,i){
14671 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14672 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14673 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14674 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>';
14675 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;
14676 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;
14677 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+'"/>';
14678 var pct=Math.round((d.code||0)/tot2*100);
14679 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14680 });
14681 } else {
14682 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14683 LANG_D.forEach(function(d,i){
14684 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14685 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14686 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>';
14687 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;
14688 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;
14689 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+'"/>';
14690 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>';
14691 });
14692 }
14693 var ly=SH-legendH+4;
14694 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>';
14695 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>';
14696 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>';
14697 s+='</svg>';
14698 el.innerHTML=s;
14699 }
14700 renderComposition('abs');
14701 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14702 btn.addEventListener('click',function(){
14703 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14704 btn.classList.add('active');
14705 renderComposition(btn.getAttribute('data-rcomp'));
14706 });
14707 });
14708
14709 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14710 (function(){
14711 var el=document.getElementById('r-scatter-chart');
14712 if(!el||!SCAT_D||!SCAT_D.length)return;
14713 var H=224,PL=52,PB=36,PT=12,PR=14;
14714 var W=Math.max(320,el.offsetWidth||480);
14715 var cW=W-PL-PR,cH=H-PT-PB;
14716 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14717 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14718 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14719 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">';
14720 [0,0.25,0.5,0.75,1].forEach(function(t){
14721 var y=PT+cH*(1-t);
14722 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14723 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>';
14724 });
14725 [0,0.25,0.5,0.75,1].forEach(function(t){
14726 var x=PL+cW*t;
14727 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14728 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>';
14729 });
14730 SCAT_D.forEach(function(d,i){
14731 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14732 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14733 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"/>';
14734 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>';
14735 });
14736 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>';
14737 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>';
14738 s+='</svg>';
14739 el.innerHTML=s;
14740 })();
14741
14742 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14743 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14744 // the old vertical column layout on wide containers.
14745 function renderSemantic(key){
14746 var el=document.getElementById('r-semantic-chart');
14747 if(!el||!SEM_D||!SEM_D.length)return;
14748 var LW=112,SH=224;
14749 var svgW=Math.max(320,el.offsetWidth||480);
14750 var BW=Math.max(120,svgW-LW-80);
14751 var topPad=4,botPad=14;
14752 var n2=SEM_D.length||1;
14753 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14754 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14755 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14756 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">';
14757 SEM_D.forEach(function(d,i){
14758 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14759 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>';
14760 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"/>';
14761 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>';
14762 });
14763 s+='</svg>';
14764 el.innerHTML=s;
14765 }
14766 var semSel=document.getElementById('r-semantic-metric');
14767 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14768
14769 // ── Submodule: horizontal bar chart ────────────────────────────────────
14770 function renderSubmodule(key,sort){
14771 var el=document.getElementById('r-submodule-chart');
14772 if(!el||!SUB_D||!SUB_D.length)return;
14773 var data=SUB_D.slice();
14774 if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14775 else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14776 else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14777 var LW=128,SH=224;
14778 var svgW=Math.max(320,el.offsetWidth||480);
14779 var BW=Math.max(120,svgW-LW-80);
14780 var topPad3=4,botPad3=14;
14781 var n3=data.length||1;
14782 var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14783 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14784 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14785 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">';
14786 data.forEach(function(d,i){
14787 var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14788 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>';
14789 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"/>';
14790 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>';
14791 });
14792 s+='</svg>';
14793 el.innerHTML=s;
14794 }
14795 var subSel=document.getElementById('r-sub-metric');
14796 var sortSel=document.getElementById('r-sub-sort');
14797 if(subSel){
14798 renderSubmodule('code','desc');
14799 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14800 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14801 }
14802
14803 // Re-render all SVG charts when the window is resized so bars fill the card.
14804 var _rResizeTimer;
14805 window.addEventListener('resize',function(){
14806 clearTimeout(_rResizeTimer);
14807 _rResizeTimer=setTimeout(function(){
14808 var rcompBtn=document.querySelector('[data-rcomp].active');
14809 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14810 (function(){
14811 var scEl=document.getElementById('r-scatter-chart');
14812 if(!scEl||!SCAT_D||!SCAT_D.length)return;
14813 var H=224,PL=52,PB=36,PT=12,PR=14;
14814 var W=Math.max(320,scEl.offsetWidth||480);
14815 var cW=W-PL-PR,cH=H-PT-PB;
14816 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14817 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14818 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14819 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">';
14820 [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>';});
14821 [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>';});
14822 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>';});
14823 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>';
14824 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>';
14825 s+='</svg>';scEl.innerHTML=s;
14826 })();
14827 if(semSel)renderSemantic(semSel.value||'functions');
14828 if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14829 },120);
14830 });
14831 })();
14832
14833 (function randomizeWatermarks() {
14834 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14835 if (!wms.length) return;
14836 var placed = [];
14837 function tooClose(top, left) {
14838 for (var i = 0; i < placed.length; i++) {
14839 var dt = Math.abs(placed[i][0] - top);
14840 var dl = Math.abs(placed[i][1] - left);
14841 if (dt < 20 && dl < 18) return true;
14842 }
14843 return false;
14844 }
14845 function pick(leftBand) {
14846 for (var attempt = 0; attempt < 50; attempt++) {
14847 var top = Math.random() * 85 + 5;
14848 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14849 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14850 }
14851 var top = Math.random() * 85 + 5;
14852 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14853 placed.push([top, left]);
14854 return [top, left];
14855 }
14856 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14857 var half = Math.floor(wms.length / 2);
14858 wms.forEach(function (img, i) {
14859 var pos = pick(i < half);
14860 var size = Math.floor(Math.random() * 100 + 160);
14861 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14862 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14863 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;
14864 });
14865 })();
14866
14867 (function spawnCodeParticles() {
14868 var container = document.getElementById('code-particles');
14869 if (!container) return;
14870 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'];
14871 for (var i = 0; i < 38; i++) {
14872 (function(idx) {
14873 var el = document.createElement('span');
14874 el.className = 'code-particle';
14875 el.textContent = snippets[idx % snippets.length];
14876 var left = Math.random() * 94 + 2;
14877 var top = Math.random() * 88 + 6;
14878 var dur = (Math.random() * 10 + 9).toFixed(1);
14879 var delay = (Math.random() * 18).toFixed(1);
14880 var rot = (Math.random() * 26 - 13).toFixed(1);
14881 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14882 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';
14883 container.appendChild(el);
14884 })(i);
14885 }
14886 })();
14887
14888 {% if pdf_generating %}
14889 // Poll for PDF readiness and swap the disabled button to a live link once done.
14890 (function() {
14891 var openBtn = document.getElementById('pdf-open-btn');
14892 var dlBtn = document.getElementById('pdf-download-btn');
14893 function checkPdf() {
14894 fetch('/api/runs/{{ run_id }}/pdf-status')
14895 .then(function(r) { return r.json(); })
14896 .then(function(d) {
14897 if (d.ready) {
14898 if (openBtn) {
14899 var a = document.createElement('a');
14900 a.className = 'button';
14901 a.id = 'pdf-open-btn';
14902 a.href = '/runs/pdf/{{ run_id }}';
14903 a.target = '_blank';
14904 a.rel = 'noopener';
14905 a.textContent = 'Open PDF';
14906 openBtn.replaceWith(a);
14907 }
14908 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14909 } else {
14910 setTimeout(checkPdf, 3000);
14911 }
14912 })
14913 .catch(function() { setTimeout(checkPdf, 5000); });
14914 }
14915 setTimeout(checkPdf, 3000);
14916 })();
14917 {% endif %}
14918
14919 })();
14920 </script>
14921 <script nonce="{{ csp_nonce }}">
14922 (function(){
14923 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'}];
14924 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);});}
14925 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14926 function init(){
14927 var btn=document.getElementById('settings-btn');if(!btn)return;
14928 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14929 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>';
14930 document.body.appendChild(m);
14931 var g=document.getElementById('scheme-grid');
14932 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);});
14933 var cl=document.getElementById('settings-close');
14934 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);
14935 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');});
14936 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14937 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14938 }
14939 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14940 }());
14941 </script>
14942 <footer class="site-footer">
14943 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
14944 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14945 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14946 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14947 · <a href="/api-docs" rel="noopener">REST API</a>
14948 </footer>
14949 {% if confluence_configured %}
14950 <script nonce="{{ csp_nonce }}">
14951 (function() {
14952 var postBtn = document.getElementById('postConfluenceBtn');
14953 var copyBtn = document.getElementById('copyWikiBtn');
14954 var modal = document.getElementById('confluenceModal');
14955 if (!postBtn || !modal) return;
14956
14957 postBtn.addEventListener('click', function() {
14958 document.getElementById('confStatus').style.display = 'none';
14959 modal.style.display = 'flex';
14960 });
14961 document.getElementById('confCancelBtn').addEventListener('click', function() {
14962 modal.style.display = 'none';
14963 });
14964 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14965
14966 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14967 var btn = this;
14968 btn.disabled = true;
14969 var status = document.getElementById('confStatus');
14970 status.style.display = 'block';
14971 status.style.background = '#dbeafe';
14972 status.style.color = '#1e40af';
14973 status.textContent = 'Posting to Confluence…';
14974 var resp = await fetch('/api/confluence/post', {
14975 method: 'POST',
14976 headers: { 'Content-Type': 'application/json' },
14977 body: JSON.stringify({
14978 run_id: '{{ run_id }}',
14979 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14980 report_url: document.getElementById('confReportUrl').value.trim() || null
14981 })
14982 });
14983 var data = await resp.json();
14984 if (data.ok) {
14985 status.style.background = '#dcfce7'; status.style.color = '#166534';
14986 status.textContent = 'Posted! Page ID: ' + data.page_id;
14987 } else {
14988 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14989 status.textContent = 'Error: ' + (data.error || 'Unknown error');
14990 }
14991 btn.disabled = false;
14992 });
14993
14994 if (copyBtn) {
14995 copyBtn.addEventListener('click', async function() {
14996 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14997 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14998 var text = await resp.text();
14999 try {
15000 await navigator.clipboard.writeText(text);
15001 var orig = copyBtn.textContent;
15002 copyBtn.textContent = 'Copied!';
15003 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
15004 } catch(e) {
15005 alert('Clipboard write failed — check browser permissions.');
15006 }
15007 });
15008 }
15009 })();
15010 </script>
15011 {% endif %}
15012 {% if let Some(banner) = report_header_footer %}
15013 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
15014 {% endif %}
15015</body>
15016</html>
15017"##,
15018 ext = "html"
15019)]
15020#[allow(clippy::struct_excessive_bools)]
15022struct ResultTemplate {
15023 version: &'static str,
15024 report_title: String,
15025 project_path: String,
15026 output_dir: String,
15027 run_id: String,
15028 files_analyzed: u64,
15029 files_skipped: u64,
15030 physical_lines: u64,
15031 code_lines: u64,
15032 comment_lines: u64,
15033 blank_lines: u64,
15034 mixed_lines: u64,
15035 functions: u64,
15036 classes: u64,
15037 variables: u64,
15038 imports: u64,
15039 html_url: Option<String>,
15040 pdf_url: Option<String>,
15041 json_url: Option<String>,
15042 html_download_url: Option<String>,
15043 pdf_download_url: Option<String>,
15044 json_download_url: Option<String>,
15045 html_path: Option<String>,
15046 pdf_path: Option<String>,
15047 json_path: Option<String>,
15048 prev_run_id: Option<String>,
15049 prev_run_timestamp: Option<String>,
15050 prev_run_code_lines: Option<u64>,
15051 prev_fa_str: String,
15053 prev_fs_str: String,
15054 prev_pl_str: String,
15055 prev_cl_str: String,
15056 prev_cml_str: String,
15057 prev_bl_str: String,
15058 delta_fa_str: String,
15060 delta_fa_class: String,
15061 delta_fs_str: String,
15062 delta_fs_class: String,
15063 delta_pl_str: String,
15064 delta_pl_class: String,
15065 delta_cl_str: String,
15066 delta_cl_class: String,
15067 delta_cml_str: String,
15068 delta_cml_class: String,
15069 delta_bl_str: String,
15070 delta_bl_class: String,
15071 delta_lines_added: Option<i64>,
15073 delta_lines_removed: Option<i64>,
15074 delta_lines_net_str: String,
15075 delta_lines_net_class: String,
15076 delta_files_added: Option<usize>,
15077 delta_files_removed: Option<usize>,
15078 delta_files_modified: Option<usize>,
15079 delta_files_unchanged: Option<usize>,
15080 delta_unmodified_lines: Option<u64>,
15081 git_branch: Option<String>,
15083 git_commit: Option<String>,
15084 git_author: Option<String>,
15085 prev_scan_count: usize,
15087 current_scan_number: usize,
15088 submodule_rows: Vec<SubmoduleRow>,
15090 scan_config_url: String,
15091 lang_chart_json: String,
15092 #[allow(dead_code)]
15094 scatter_chart_json: String,
15095 #[allow(dead_code)]
15096 semantic_chart_json: String,
15097 #[allow(dead_code)]
15098 submodule_chart_json: String,
15099 #[allow(dead_code)]
15100 has_submodule_data: bool,
15101 #[allow(dead_code)]
15102 has_semantic_data: bool,
15103 pdf_generating: bool,
15104 csp_nonce: String,
15105 confluence_configured: bool,
15107 report_header_footer: Option<String>,
15109}
15110
15111#[derive(Template)]
15112#[template(
15113 source = r##"
15114<!doctype html>
15115<html lang="en">
15116<head>
15117 <meta charset="utf-8">
15118 <meta name="viewport" content="width=device-width, initial-scale=1">
15119 <title>OxideSLOC | Analyzing…</title>
15120 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15121 <style nonce="{{ csp_nonce }}">
15122 :root {
15123 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15124 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15125 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15126 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15127 }
15128 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15129 *{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);}
15130 .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);}
15131 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15132 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
15133 .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));}
15134 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15135 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15136 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15137 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15138 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15139 @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; } }
15140 .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;}
15141 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15142 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15143 .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
15144 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
15145 .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;}
15146 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
15147 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
15148 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
15149 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
15150 .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;}
15151 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
15152 .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;}
15153 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
15154 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
15155 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
15156 .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;}
15157 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
15158 .hidden{display:none!important;}
15159 .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;}
15160 .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;}
15161 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
15162 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
15163 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
15164 .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);}
15165 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
15166 .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;}
15167 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
15168 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15169 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15170 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
15171 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15172 .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;}
15173 @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));}}
15174 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15175 .site-footer a{color:var(--muted);}
15176 .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;}
15177 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
15178 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
15179 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
15180 </style>
15181</head>
15182<body>
15183 <div class="background-watermarks" aria-hidden="true">
15184 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15185 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15186 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15187 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15188 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15189 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15190 </div>
15191 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15192 <nav class="top-nav">
15193 <div class="top-nav-inner">
15194 <a href="/" class="brand">
15195 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
15196 <div class="brand-copy">
15197 <h1 class="brand-title">OxideSLOC</h1>
15198 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15199 </div>
15200 </a>
15201 <div class="nav-right">
15202 <a class="nav-pill" href="/">Home</a>
15203 <div class="nav-dropdown">
15204 <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>
15205 <div class="nav-dropdown-menu">
15206 <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>
15207 </div>
15208 </div>
15209 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15210 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15211 <div class="nav-dropdown">
15212 <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>
15213 <div class="nav-dropdown-menu">
15214 <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>
15215 </div>
15216 </div>
15217 <div class="server-status-wrap">
15218 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15219 <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>
15220 </div>
15221 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15222 <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>
15223 </button>
15224 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15225 <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>
15226 <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>
15227 </button>
15228 </div>
15229 </div>
15230 </nav>
15231 <div class="page-body">
15232 <div class="wait-panel">
15233 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15234 <h2 class="wait-title">Analyzing your project…</h2>
15235 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15236 <div class="path-block">{{ project_path }}</div>
15237 <div class="metrics-row">
15238 <div class="metric-card">
15239 <div class="metric-label">Elapsed</div>
15240 <div class="metric-value" id="elapsed">0s</div>
15241 </div>
15242 <div class="metric-card">
15243 <div class="metric-label">Phase</div>
15244 <div class="metric-value" id="phase">Starting</div>
15245 </div>
15246 </div>
15247 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15248 <div class="warn-slow hidden" id="warn-slow">
15249 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.
15250 </div>
15251 <div class="err-panel hidden" id="err-panel">
15252 <strong>Analysis failed</strong>
15253 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15254 </div>
15255 <div class="actions hidden" id="actions">
15256 <a href="/scan" class="btn-primary">Try Again</a>
15257 <a href="/view-reports" class="btn-outline">View Reports</a>
15258 </div>
15259 </div>
15260 </div>
15261 <script nonce="{{ csp_nonce }}">
15262 (function() {
15263 var WAIT_ID = {{ wait_id_json|safe }};
15264 var startTime = Date.now();
15265 var pollInterval = 1500;
15266 var retries = 0;
15267 var maxRetries = 5;
15268 var warnShown = false;
15269
15270 function elapsed() {
15271 return Math.floor((Date.now() - startTime) / 1000);
15272 }
15273
15274 function updateElapsed() {
15275 var s = elapsed();
15276 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15277 }
15278
15279 function setPhase(txt) {
15280 document.getElementById('phase').textContent = txt;
15281 }
15282
15283 var elapsedTimer = setInterval(updateElapsed, 1000);
15284
15285 function poll() {
15286 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15287 .then(function(r) {
15288 if (!r.ok) throw new Error('HTTP ' + r.status);
15289 return r.json();
15290 })
15291 .then(function(data) {
15292 retries = 0;
15293 if (data.state === 'complete') {
15294 clearInterval(elapsedTimer);
15295 setPhase('Done');
15296 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15297 } else if (data.state === 'failed') {
15298 clearInterval(elapsedTimer);
15299 setPhase('Failed');
15300 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15301 document.getElementById('err-panel').classList.remove('hidden');
15302 document.getElementById('actions').classList.remove('hidden');
15303 } else {
15304 // still running
15305 var s = elapsed();
15306 if (s > 90 && !warnShown) {
15307 warnShown = true;
15308 document.getElementById('warn-slow').classList.remove('hidden');
15309 }
15310 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15311 setTimeout(poll, pollInterval);
15312 }
15313 })
15314 .catch(function(err) {
15315 retries++;
15316 if (retries >= maxRetries) {
15317 clearInterval(elapsedTimer);
15318 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15319 document.getElementById('err-panel').classList.remove('hidden');
15320 document.getElementById('actions').classList.remove('hidden');
15321 } else {
15322 // exponential back-off capped at 8s
15323 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15324 }
15325 });
15326 }
15327
15328 setTimeout(poll, pollInterval);
15329 })();
15330 </script>
15331 <footer class="site-footer">
15332 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
15333 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15334 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15335 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15336 · <a href="/api-docs" rel="noopener">REST API</a>
15337 </footer>
15338 <script nonce="{{ csp_nonce }}">
15339 (function(){
15340 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15341 if(s==="dark")b.classList.add("dark-theme");
15342 var tt=document.getElementById("theme-toggle");
15343 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15344 })();
15345 (function spawnCodeParticles(){
15346 var c=document.getElementById('code-particles');if(!c)return;
15347 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'];
15348 for(var i=0;i<32;i++){(function(idx){
15349 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15350 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15351 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15352 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15353 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15354 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15355 c.appendChild(el);
15356 })(i);}
15357 })();
15358 (function randomizeWatermarks(){
15359 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15360 var placed=[];
15361 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;}
15362 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];}
15363 var half=Math.floor(wms.length/2);
15364 wms.forEach(function(img,i){
15365 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15366 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15367 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15368 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15369 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15370 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15371 });
15372 })();
15373 </script>
15374 <script nonce="{{ csp_nonce }}">
15375 (function(){
15376 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'}];
15377 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);});}
15378 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15379 function init(){
15380 var btn=document.getElementById('settings-btn');if(!btn)return;
15381 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15382 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>';
15383 document.body.appendChild(m);
15384 var g=document.getElementById('scheme-grid');
15385 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);});
15386 var cl=document.getElementById('settings-close');
15387 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);
15388 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');});
15389 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15390 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15391 }
15392 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15393 }());
15394 </script>
15395</body>
15396</html>
15397"##,
15398 ext = "html"
15399)]
15400struct ScanWaitTemplate {
15401 version: &'static str,
15402 wait_id_json: String,
15403 project_path: String,
15404 csp_nonce: String,
15405}
15406
15407#[derive(Template)]
15408#[template(
15409 source = r##"
15410<!doctype html>
15411<html lang="en">
15412<head>
15413 <meta charset="utf-8">
15414 <meta name="viewport" content="width=device-width, initial-scale=1">
15415 <title>OxideSLOC | Error</title>
15416 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15417 <style nonce="{{ csp_nonce }}">
15418 :root {
15419 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15420 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15421 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15422 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15423 }
15424 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15425 *{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);}
15426 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15427 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15428 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15429 .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);}
15430 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15431 .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));}
15432 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15433 .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;}
15434 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15435 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15436 @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; } }
15437 .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;}
15438 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15439 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15440 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15441 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15442 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15443 .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;}
15444 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15445 .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);}
15446 .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;}
15447 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15448 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15449 .settings-modal-body{padding:14px 16px 16px;}
15450 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15451 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15452 .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;}
15453 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15454 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15455 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15456 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15457 .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;}
15458 .tz-select:focus{border-color:var(--oxide);}
15459 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15460 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15461 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15462 .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;}
15463 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15464 .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);}
15465 .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;}
15466 .btn-secondary:hover{background:var(--line);}
15467 .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;}
15468 .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;}
15469 .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;}
15470 @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));}}
15471 .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;}
15472 </style>
15473</head>
15474<body>
15475 <div class="background-watermarks" aria-hidden="true">
15476 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15477 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15478 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15479 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15480 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15481 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15482 </div>
15483 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15484 <div class="top-nav">
15485 <div class="top-nav-inner">
15486 <a class="brand" href="/">
15487 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15488 <div class="brand-copy">
15489 <div class="brand-title">OxideSLOC</div>
15490 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15491 </div>
15492 </a>
15493 <div class="nav-right">
15494 <a class="nav-pill" href="/">Home</a>
15495 <div class="nav-dropdown">
15496 <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>
15497 <div class="nav-dropdown-menu">
15498 <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>
15499 </div>
15500 </div>
15501 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15502 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15503 <div class="nav-dropdown">
15504 <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>
15505 <div class="nav-dropdown-menu">
15506 <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>
15507 </div>
15508 </div>
15509 <div class="server-status-wrap">
15510 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15511 <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>
15512 </div>
15513 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15514 <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>
15515 </button>
15516 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15517 <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>
15518 <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>
15519 </button>
15520 </div>
15521 </div>
15522 </div>
15523
15524 <div class="page">
15525 <div class="panel">
15526 <h1>Error</h1>
15527 <div class="error-box">{{ message }}</div>
15528 <div class="actions">
15529 <a class="btn-primary" href="/scan">Back to setup</a>
15530 {% if let Some(report_url) = last_report_url %}
15531 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15532 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15533 {% else %}
15534 <a class="btn-secondary" href="/view-reports">View Reports</a>
15535 {% endif %}
15536 </div>
15537 </div>
15538 </div>
15539 <script nonce="{{ csp_nonce }}">
15540 (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");});})();
15541 (function spawnCodeParticles() {
15542 var container = document.getElementById('code-particles');
15543 if (!container) return;
15544 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'];
15545 for (var i = 0; i < 38; i++) {
15546 (function(idx) {
15547 var el = document.createElement('span');
15548 el.className = 'code-particle';
15549 el.textContent = snippets[idx % snippets.length];
15550 var left = Math.random() * 94 + 2;
15551 var top = Math.random() * 88 + 6;
15552 var dur = (Math.random() * 10 + 9).toFixed(1);
15553 var delay = (Math.random() * 18).toFixed(1);
15554 var rot = (Math.random() * 26 - 13).toFixed(1);
15555 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15556 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';
15557 container.appendChild(el);
15558 })(i);
15559 }
15560 })();
15561 (function randomizeWatermarks() {
15562 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15563 var placed = [];
15564 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; }
15565 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]; }
15566 var half = Math.floor(wms.length/2);
15567 wms.forEach(function(img, i) {
15568 var pos = pick(i < half);
15569 var w = Math.floor(Math.random()*60+80);
15570 var rot = (Math.random()*40-20).toFixed(1);
15571 var op = (Math.random()*0.08+0.05).toFixed(2);
15572 var animDur = (Math.random()*6+5).toFixed(1);
15573 var animDelay = (Math.random()*10).toFixed(1);
15574 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';
15575 });
15576 })();
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 </script>
15599</body>
15600</html>
15601"##,
15602 ext = "html"
15603)]
15604struct ErrorTemplate {
15605 message: String,
15606 last_report_url: Option<String>,
15608 last_report_label: Option<String>,
15610 csp_nonce: String,
15611}
15612
15613#[derive(Template)]
15616#[template(
15617 source = r##"
15618<!doctype html>
15619<html lang="en">
15620<head>
15621 <meta charset="utf-8">
15622 <meta name="viewport" content="width=device-width, initial-scale=1">
15623 <title>OxideSLOC | Locate Scan Files</title>
15624 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15625 <style nonce="{{ csp_nonce }}">
15626 :root {
15627 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15628 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15629 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15630 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15631 }
15632 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15633 *{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);}
15634 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15635 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15636 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15637 .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);}
15638 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15639 .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));}
15640 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15641 .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;}
15642 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15643 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15644 @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;}}
15645 .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;}
15646 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15647 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15648 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15649 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15650 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15651 .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;}
15652 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15653 .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);}
15654 .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;}
15655 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15656 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15657 .settings-modal-body{padding:14px 16px 16px;}
15658 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15659 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15660 .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;}
15661 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15662 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15663 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15664 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15665 .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;}
15666 .tz-select:focus{border-color:var(--oxide);}
15667 .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15668 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15669 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15670 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15671 .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;}
15672 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15673 .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;}
15674 .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;}
15675 .btn-secondary:hover{background:var(--line);}
15676 .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;}
15677 .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;}
15678 .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;}
15679 @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));}}
15680 .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;}
15681 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15682 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15683 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15684 .relocate-row{display:flex;gap:8px;align-items:stretch;}
15685 .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;}
15686 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15687 body.dark-theme .relocate-input{background:var(--surface-2);}
15688 </style>
15689</head>
15690<body>
15691 <div class="background-watermarks" aria-hidden="true">
15692 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15693 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15694 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15695 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15696 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15697 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15698 </div>
15699 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15700 <div class="top-nav">
15701 <div class="top-nav-inner">
15702 <a class="brand" href="/">
15703 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15704 <div class="brand-copy">
15705 <div class="brand-title">OxideSLOC</div>
15706 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15707 </div>
15708 </a>
15709 <div class="nav-right">
15710 <a class="nav-pill" href="/">Home</a>
15711 <div class="nav-dropdown">
15712 <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>
15713 <div class="nav-dropdown-menu">
15714 <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>
15715 </div>
15716 </div>
15717 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15718 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15719 <div class="nav-dropdown">
15720 <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>
15721 <div class="nav-dropdown-menu">
15722 <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>
15723 </div>
15724 </div>
15725 <div class="server-status-wrap">
15726 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15727 <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>
15728 </div>
15729 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15730 <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>
15731 </button>
15732 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15733 <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>
15734 <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>
15735 </button>
15736 </div>
15737 </div>
15738 </div>
15739
15740 <div class="page">
15741 <div class="panel">
15742 <h1>Scan Files Moved</h1>
15743 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15744 <div class="error-box">{{ message }}</div>
15745 <div class="relocate-section">
15746 <h2>Locate Scan Output</h2>
15747 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15748 <form method="post" action="/relocate-scan">
15749 <input type="hidden" name="run_id" value="{{ run_id }}">
15750 <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15751 <div class="relocate-row">
15752 <input type="text" id="relocate-folder" name="folder_path"
15753 value="{{ folder_hint }}"
15754 placeholder="Path to folder containing scan output..."
15755 class="relocate-input" autocomplete="off" spellcheck="false">
15756 {% if !server_mode %}
15757 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
15758 {% endif %}
15759 </div>
15760 <div style="margin-top:12px;">
15761 <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15762 </div>
15763 </form>
15764 </div>
15765 <div class="actions">
15766 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15767 <a class="btn-secondary" href="/view-reports">View Reports</a>
15768 </div>
15769 </div>
15770 </div>
15771 <script nonce="{{ csp_nonce }}">
15772 (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");});})();
15773 (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);}})();
15774 (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';});})();
15775 </script>
15776 <script nonce="{{ csp_nonce }}">
15777 (function(){
15778 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'}];
15779 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);});}
15780 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15781 function init(){
15782 var btn=document.getElementById('settings-btn');if(!btn)return;
15783 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15784 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>';
15785 document.body.appendChild(m);
15786 var g=document.getElementById('scheme-grid');
15787 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);});
15788 var cl=document.getElementById('settings-close');
15789 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);
15790 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');});
15791 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15792 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15793 }
15794 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15795 }());
15796 (function(){
15797 var btn=document.getElementById('browse-relocate-btn');
15798 if(!btn)return;
15799 btn.addEventListener('click',function(){
15800 btn.disabled=true;btn.textContent='...';
15801 var inp=document.getElementById('relocate-folder');
15802 var hint=inp?inp.value:'';
15803 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
15804 .then(function(r){return r.json();})
15805 .then(function(d){
15806 btn.disabled=false;btn.textContent='Browse…';
15807 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15808 })
15809 .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15810 });
15811 }());
15812 </script>
15813</body>
15814</html>
15815"##,
15816 ext = "html"
15817)]
15818struct RelocateScanTemplate {
15819 message: String,
15820 run_id: String,
15821 folder_hint: String,
15822 redirect_url: String,
15823 server_mode: bool,
15824 csp_nonce: String,
15825}
15826
15827#[derive(Template)]
15830#[template(
15831 source = r##"
15832<!doctype html>
15833<html lang="en">
15834<head>
15835 <meta charset="utf-8">
15836 <meta name="viewport" content="width=device-width, initial-scale=1">
15837 <title>OxideSLOC | View Reports</title>
15838 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15839 <style nonce="{{ csp_nonce }}">
15840 :root {
15841 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15842 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15843 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15844 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15845 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15846 }
15847 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; }
15848 *{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);}
15849 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15850 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15851 .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);}
15852 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15853 .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));}
15854 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15855 .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;}
15856 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15857 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15858 @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; } }
15859 .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;}
15860 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15861 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15862 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15863 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15864 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15865 .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;}
15866 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15867 .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);}
15868 .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;}
15869 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15870 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15871 .settings-modal-body{padding:14px 16px 16px;}
15872 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15873 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15874 .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;}
15875 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15876 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15877 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15878 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15879 .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;}
15880 .tz-select:focus{border-color:var(--oxide);}
15881 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15882 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15883 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15884 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15885 .panel-meta{font-size:13px;color:var(--muted);}
15886 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15887 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15888 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15889 .per-page-label{font-size:13px;color:var(--muted);}
15890 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;}
15891 .filter-input{min-width:180px;cursor:text;}
15892 .table-wrap{width:100%;overflow-x:auto;}
15893 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15894 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;}
15895 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15896 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15897 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15898 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15899 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15900 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15901 tr:last-child td{border-bottom:none;}
15902 tr:hover td{background:var(--surface-2);}
15903 .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);}
15904 .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);}
15905 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15906 .metric-num{font-weight:700;color:var(--text);}
15907 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15908 .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;}
15909 .btn:hover{background:var(--line);}
15910 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15911 .btn.primary:hover{opacity:.9;}
15912 .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;}
15913 .btn-back:hover{background:var(--line);}
15914 .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;}
15915 .export-btn:hover{background:var(--line);}
15916 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15917 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15918 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15919 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15920 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15921 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15922 .pagination-info{font-size:13px;color:var(--muted);}
15923 .pagination-btns{display:flex;gap:6px;}
15924 .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;}
15925 .pg-btn:hover:not(:disabled){background:var(--line);}
15926 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15927 .pg-btn:disabled{opacity:.35;cursor:default;}
15928 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15929 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15930 .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;}
15931 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15932 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15933 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15934 .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);}
15935 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15936 .stat-chip:hover .stat-chip-tip{opacity:1;}
15937 .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;}
15938 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15939 .site-footer a{color:var(--muted);}
15940 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15941 .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%;}
15942 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15943 .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;}
15944 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15945 .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;}
15946 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15947 .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;}
15948 .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;}
15949 .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;}
15950 @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));}}
15951 .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;}
15952 .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;}
15953 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15954 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15955 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15956 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15957 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15958 .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;}
15959 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15960 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15961 .watched-chip-rm:hover{color:var(--oxide);}
15962 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15963 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15964 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
15965 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15966 .rpt-btn{min-width:58px;justify-content:center;}
15967 .flex-row{display:flex;align-items:center;gap:8px;}
15968 .report-cell{overflow:visible;white-space:normal;}
15969 #history-table col:nth-child(1){width:185px;}
15970 #history-table col:nth-child(2){width:220px;}
15971 #history-table col:nth-child(3){width:100px;}
15972 #history-table col:nth-child(4){width:72px;}
15973 #history-table col:nth-child(5){width:82px;}
15974 #history-table col:nth-child(6){width:82px;}
15975 #history-table col:nth-child(7){width:65px;}
15976 #history-table col:nth-child(8){width:90px;}
15977 #history-table col:nth-child(9){width:85px;}
15978 #history-table col:nth-child(10){width:115px;}
15979 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15980 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15981 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15982 .submod-details summary::-webkit-details-marker{display:none;}
15983.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15984 .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;}
15985 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15986 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15987 </style>
15988</head>
15989<body>
15990 <div class="background-watermarks" aria-hidden="true">
15991 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15992 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15993 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15994 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15995 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15996 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15997 </div>
15998 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15999 <div class="top-nav">
16000 <div class="top-nav-inner">
16001 <a class="brand" href="/">
16002 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16003 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
16004 </a>
16005 <div class="nav-right">
16006 <a class="nav-pill" href="/">Home</a>
16007 <div class="nav-dropdown">
16008 <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
16009 <div class="nav-dropdown-menu">
16010 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
16011 </div>
16012 </div>
16013 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16014 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16015 <div class="nav-dropdown">
16016 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
16017 <div class="nav-dropdown-menu">
16018 <a href="/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>
16019 </div>
16020 </div>
16021 <div class="server-status-wrap">
16022 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16023 <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>
16024 </div>
16025 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16026 <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>
16027 </button>
16028 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16029 <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>
16030 <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>
16031 </button>
16032 </div>
16033 </div>
16034 </div>
16035
16036 <div class="page">
16037 {% if let Some(err) = browse_error %}
16038 <div class="toast-error">
16039 <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>
16040 {{ err }}
16041 </div>
16042 {% endif %}
16043 {% if linked_count > 0 %}
16044 <div class="toast-success">
16045 <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>
16046 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
16047 </div>
16048 {% endif %}
16049 <div class="watched-bar">
16050 <div class="watched-bar-left">
16051 <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>
16052 <span class="watched-label">Watched Folders</span>
16053 <div class="watched-chips">
16054 {% for dir in watched_dirs %}
16055 <span class="watched-chip">
16056 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16057 <form method="POST" action="/watched-dirs/remove" style="display:contents">
16058 <input type="hidden" name="folder_path" value="{{ dir }}">
16059 <input type="hidden" name="redirect_to" value="/view-reports">
16060 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
16061 </form>
16062 </span>
16063 {% endfor %}
16064 {% if watched_dirs.is_empty() %}
16065 <span class="watched-none">No folders watched — click Choose to add one</span>
16066 {% endif %}
16067 </div>
16068 </div>
16069 <div class="watched-bar-right">
16070 <button type="button" class="btn" id="add-watched-btn">
16071 <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>
16072 Choose
16073 </button>
16074 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16075 <input type="hidden" name="redirect_to" value="/view-reports">
16076 <button type="submit" class="btn">↻ Refresh</button>
16077 </form>
16078 </div>
16079 </div>
16080 {% if total_scans > 0 %}
16081 <div class="summary-strip">
16082 <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>
16083 <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>
16084 <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>
16085 <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>
16086 </div>
16087 {% endif %}
16088
16089 <section class="panel">
16090 <div class="panel-header">
16091 <div>
16092 <h1>View Reports</h1>
16093 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
16094 </div>
16095 <div class="flex-row">
16096 <button type="button" class="export-btn" id="export-csv-btn">
16097 <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>
16098 Export CSV
16099 </button>
16100 <button type="button" class="export-btn" id="export-xls-btn">
16101 <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>
16102 Export Excel
16103 </button>
16104 </div>
16105 </div>
16106
16107 {% if entries.is_empty() %}
16108 <div class="empty-state">
16109 <strong>No reports with viewable HTML yet</strong>
16110 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.
16111 </div>
16112 {% else %}
16113 <div class="filter-row">
16114 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16115 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16116 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
16117 </div>
16118 <div class="table-wrap">
16119 <table id="history-table">
16120 <colgroup>
16121 <col><col><col><col><col><col><col><col><col><col>
16122 </colgroup>
16123 <thead>
16124 <tr id="history-thead">
16125 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16126 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16127 <th>Run ID<div class="col-resize-handle"></div></th>
16128 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16129 <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>
16130 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16131 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16132 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16133 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16134 <th>Report<div class="col-resize-handle"></div></th>
16135 </tr>
16136 </thead>
16137 <tbody id="history-tbody">
16138 {% for entry in entries %}
16139 <tr class="history-row" data-run="{{ entry.run_id }}"
16140 data-timestamp="{{ entry.timestamp }}"
16141 data-project="{{ entry.project_label }}"
16142 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
16143 data-skipped="{{ entry.files_skipped }}"
16144 data-comments="{{ entry.comment_lines }}"
16145 data-blank="{{ entry.blank_lines }}"
16146 data-branch="{{ entry.git_branch }}"
16147 data-commit="{{ entry.git_commit }}"
16148 data-html-url="/runs/html/{{ entry.run_id }}">
16149 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16150 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16151 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
16152 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
16153 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16154 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16155 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16156 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
16157 <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>
16158 <td class="report-cell">
16159 <div class="actions-cell">
16160 {% 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 %}
16161 {% 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 %}
16162 </div>
16163 {% if !entry.submodule_links.is_empty() %}
16164 <details class="submod-details">
16165 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
16166 <div class="submod-link-list">
16167 {% for sub in entry.submodule_links %}
16168 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
16169 {% endfor %}
16170 </div>
16171 </details>
16172 {% endif %}
16173 </td>
16174 </tr>
16175 {% endfor %}
16176 </tbody>
16177 </table>
16178 </div>
16179 <div class="pagination">
16180 <span class="pagination-info" id="pagination-info"></span>
16181 <div class="pagination-btns" id="pagination-btns"></div>
16182 <div class="flex-row">
16183 <span class="per-page-label">Show</span>
16184 <select class="per-page" id="per-page-sel">
16185 <option value="10">10 per page</option>
16186 <option value="25" selected>25 per page</option>
16187 <option value="50">50 per page</option>
16188 <option value="100">100 per page</option>
16189 </select>
16190 <span class="per-page-label" id="page-range-label"></span>
16191 </div>
16192 </div>
16193 {% endif %}
16194 </section>
16195 </div>
16196
16197 <footer class="site-footer">
16198 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
16199 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16200 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16201 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16202 · <a href="/api-docs" rel="noopener">REST API</a>
16203 </footer>
16204
16205 <script nonce="{{ csp_nonce }}">
16206 (function () {
16207 // ── Theme ──────────────────────────────────────────────────────────────
16208 var storageKey = 'oxide-sloc-theme';
16209 var body = document.body;
16210 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16211 var toggle = document.getElementById('theme-toggle');
16212 if (toggle) toggle.addEventListener('click', function () {
16213 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16214 body.classList.toggle('dark-theme', next === 'dark');
16215 try { localStorage.setItem(storageKey, next); } catch(e) {}
16216 });
16217
16218 // ── State ─────────────────────────────────────────────────────────────
16219 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
16220 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
16221 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16222
16223 // Aggregate stats from first (most recent) row
16224 if (allRows.length) {
16225 var first = allRows[0];
16226 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();}
16227 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>':'');}
16228 setChipVal('agg-code', first.dataset.code);
16229 setChipVal('agg-files', first.dataset.files);
16230 setChipVal('agg-skipped', first.dataset.skipped);
16231 }
16232
16233 // ── Branch filter population ──────────────────────────────────────────
16234 (function() {
16235 var branches = {};
16236 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16237 var sel = document.getElementById('branch-filter');
16238 if (sel) Object.keys(branches).sort().forEach(function(b) {
16239 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16240 });
16241 })();
16242
16243 // ── Filter ────────────────────────────────────────────────────────────
16244 function getFilteredRows() {
16245 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16246 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16247 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16248 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16249 if (branch && (r.dataset.branch || '') !== branch) return false;
16250 return true;
16251 });
16252 }
16253
16254 // ── Pagination ────────────────────────────────────────────────────────
16255 function renderPage() {
16256 var filtered = getFilteredRows();
16257 var total = filtered.length;
16258 var totalPages = Math.max(1, Math.ceil(total / perPage));
16259 currentPage = Math.min(currentPage, totalPages);
16260 var start = (currentPage - 1) * perPage;
16261 var end = Math.min(start + perPage, total);
16262 var shown = {};
16263 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16264 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16265 r.style.display = shown[r.dataset.run] ? '' : 'none';
16266 });
16267 var rl = document.getElementById('page-range-label');
16268 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16269 var info = document.getElementById('pagination-info');
16270 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16271 var btns = document.getElementById('pagination-btns');
16272 if (!btns) return;
16273 btns.innerHTML = '';
16274 function makeBtn(lbl, pg, active, disabled) {
16275 var b = document.createElement('button');
16276 b.className = 'pg-btn' + (active ? ' active' : '');
16277 b.textContent = lbl; b.disabled = disabled;
16278 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16279 return b;
16280 }
16281 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16282 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16283 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16284 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16285 }
16286
16287 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16288 window.applyFilters = function() { currentPage = 1; renderPage(); };
16289
16290 // ── Sorting ───────────────────────────────────────────────────────────
16291 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16292 function doSort(col, type, order) {
16293 var tbody = document.getElementById('history-tbody');
16294 if (!tbody) return;
16295 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16296 rows.sort(function(a, b) {
16297 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16298 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16299 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16300 return va < vb ? 1 : va > vb ? -1 : 0;
16301 });
16302 rows.forEach(function(r) { tbody.appendChild(r); });
16303 currentPage = 1; renderPage();
16304 }
16305 sortHeaders.forEach(function(th) {
16306 th.addEventListener('click', function(e) {
16307 if (e.target.classList.contains('col-resize-handle')) return;
16308 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16309 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16310 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16311 th.classList.add('sort-' + sortOrder);
16312 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16313 doSort(col, type, sortOrder);
16314 });
16315 });
16316
16317 // ── Column resize ─────────────────────────────────────────────────────
16318 (function() {
16319 var table = document.getElementById('history-table');
16320 if (!table) return;
16321 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16322 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16323 ths.forEach(function(th, i) {
16324 var handle = th.querySelector('.col-resize-handle');
16325 if (!handle || !cols[i]) return;
16326 var startX, startW;
16327 handle.addEventListener('mousedown', function(e) {
16328 e.stopPropagation(); e.preventDefault();
16329 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16330 handle.classList.add('dragging');
16331 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16332 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16333 document.addEventListener('mousemove', onMove);
16334 document.addEventListener('mouseup', onUp);
16335 });
16336 });
16337 })();
16338
16339 // ── Reset view ────────────────────────────────────────────────────────
16340 window.resetView = function() {
16341 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16342 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16343 sortCol = null; sortOrder = 'asc';
16344 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16345 var tbody = document.getElementById('history-tbody');
16346 if (tbody) {
16347 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16348 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16349 rows.forEach(function(r) { tbody.appendChild(r); });
16350 }
16351 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16352 var table = document.getElementById('history-table');
16353 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16354 currentPage = 1; renderPage();
16355 };
16356
16357 renderPage();
16358
16359 // ── Export helpers ────────────────────────────────────────────────────
16360 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
16361 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16362 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);}
16363 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;');}
16364 function slocXlsx(fname,sheet,hdrs,rows){
16365 var enc=new TextEncoder();
16366 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;}
16367 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;}
16368 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16369 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16370 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
16371 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;}
16372 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];}
16373 var rx='<row r="1">';
16374 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16375 rx+='</row>';
16376 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>';});
16377 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16378 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>';
16379 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>';
16380 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>';
16381 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>',
16382 '_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>',
16383 '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>',
16384 '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>',
16385 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16386 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'];
16387 var zparts=[],zcds=[],zoff=0,znf=0;
16388 order.forEach(function(name){
16389 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16390 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]);
16391 var entry=new Uint8Array(lha.length+nb.length+sz);
16392 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16393 zparts.push(entry);
16394 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));
16395 var cde=new Uint8Array(cda.length+nb.length);
16396 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16397 zcds.push(cde);zoff+=entry.length;znf++;
16398 });
16399 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16400 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]);
16401 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16402 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16403 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16404 zout.set(new Uint8Array(ea),zpos);
16405 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16406 }
16407
16408 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16409 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;}
16410 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16411 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16412
16413 var csvBtn = document.getElementById('export-csv-btn');
16414 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16415 var xlsBtn = document.getElementById('export-xls-btn');
16416 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16417
16418 // ── Remaining CSP-safe event bindings ────────────────────────────────
16419 (function wireEvents() {
16420 var el;
16421 el = document.getElementById('reset-view-btn');
16422 if (el) el.addEventListener('click', window.resetView);
16423 el = document.getElementById('project-filter');
16424 if (el) el.addEventListener('input', window.applyFilters);
16425 el = document.getElementById('branch-filter');
16426 if (el) el.addEventListener('change', window.applyFilters);
16427 el = document.getElementById('per-page-sel');
16428 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16429 el = document.getElementById('add-watched-btn');
16430 if (el) el.addEventListener('click', function() {
16431 fetch('/pick-directory?kind=reports')
16432 .then(function(r) { return r.json(); })
16433 .then(function(data) {
16434 if (!data.cancelled && data.selected_path) {
16435 var form = document.createElement('form');
16436 form.method = 'POST';
16437 form.action = '/watched-dirs/add';
16438 var ri = document.createElement('input');
16439 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16440 var fi = document.createElement('input');
16441 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16442 form.appendChild(ri); form.appendChild(fi);
16443 document.body.appendChild(form);
16444 form.submit();
16445 }
16446 })
16447 .catch(function(e) { alert('Could not open folder picker: ' + e); });
16448 });
16449 })();
16450
16451 (function randomizeWatermarks() {
16452 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16453 if (!wms.length) return;
16454 var placed = [];
16455 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;}
16456 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];}
16457 var half=Math.floor(wms.length/2);
16458 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;});
16459 })();
16460
16461 (function spawnCodeParticles() {
16462 var container = document.getElementById('code-particles');
16463 if (!container) return;
16464 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'];
16465 for (var i = 0; i < 38; i++) {
16466 (function(idx) {
16467 var el = document.createElement('span');
16468 el.className = 'code-particle';
16469 el.textContent = snippets[idx % snippets.length];
16470 var left = Math.random() * 94 + 2;
16471 var top = Math.random() * 88 + 6;
16472 var dur = (Math.random() * 10 + 9).toFixed(1);
16473 var delay = (Math.random() * 18).toFixed(1);
16474 var rot = (Math.random() * 26 - 13).toFixed(1);
16475 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16476 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';
16477 container.appendChild(el);
16478 })(i);
16479 }
16480 })();
16481 })();
16482 </script>
16483 <script nonce="{{ csp_nonce }}">
16484 (function(){
16485 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'}];
16486 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);});}
16487 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16488 function init(){
16489 var btn=document.getElementById('settings-btn');if(!btn)return;
16490 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16491 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>';
16492 document.body.appendChild(m);
16493 var g=document.getElementById('scheme-grid');
16494 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);});
16495 var cl=document.getElementById('settings-close');
16496 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);
16497 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');});
16498 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16499 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16500 }
16501 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16502 }());
16503 </script>
16504</body>
16505</html>
16506"##,
16507 ext = "html"
16508)]
16509struct HistoryTemplate {
16510 version: &'static str,
16511 entries: Vec<HistoryEntryRow>,
16512 total_scans: usize,
16513 linked_count: usize,
16514 browse_error: Option<String>,
16515 watched_dirs: Vec<String>,
16516 csp_nonce: String,
16517}
16518
16519#[derive(Template)]
16522#[template(
16523 source = r##"
16524<!doctype html>
16525<html lang="en">
16526<head>
16527 <meta charset="utf-8">
16528 <meta name="viewport" content="width=device-width, initial-scale=1">
16529 <title>OxideSLOC | Compare Scans</title>
16530 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16531 <style nonce="{{ csp_nonce }}">
16532 :root {
16533 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16534 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16535 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16536 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16537 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16538 }
16539 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16540 *{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);}
16541 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16542 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16543 .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);}
16544 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16545 .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));}
16546 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16547 .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;}
16548 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16549 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16550 @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; } }
16551 .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;}
16552 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16553 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16554 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16555 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16556 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16557 .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;}
16558 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16559 .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);}
16560 .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;}
16561 .settings-close:hover{color:var(--text);background:var(--surface-2);}
16562 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16563 .settings-modal-body{padding:14px 16px 16px;}
16564 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16565 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16566 .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;}
16567 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16568 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16569 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16570 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16571 .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;}
16572 .tz-select:focus{border-color:var(--oxide);}
16573 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16574 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16575 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16576 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16577 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16578 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16579 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16580 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16581 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16582 .per-page-label{font-size:13px;color:var(--muted);}
16583 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;}
16584 .filter-input{min-width:180px;cursor:text;}
16585 .table-wrap{width:100%;overflow-x:auto;}
16586 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16587 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;}
16588 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16589 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16590 #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;}
16591 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16592 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16593 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16594 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16595 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16596 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16597 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16598 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16599 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16600 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16601 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16602 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16603 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16604 tr:last-child td{border-bottom:none;}
16605 tr.selected td{background:var(--sel-bg);}
16606 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16607 tr:hover:not(.selected) td{background:var(--surface-2);}
16608 tr{cursor:pointer;}
16609 .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);}
16610 .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);}
16611 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16612 .metric-num{font-weight:700;color:var(--text);}
16613 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16614 .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;}
16615 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16616 .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;}
16617 .btn:hover{background:var(--line);}
16618 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16619 .btn.primary:hover{opacity:.9;}
16620 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16621 .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;}
16622 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16623 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16624 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16625 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16626 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16627 .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;}
16628 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16629 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16630 .watched-chip-rm:hover{color:var(--oxide);}
16631 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16632 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16633 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
16634 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16635 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16636 .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;}
16637 .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;}
16638 .btn-back:hover{background:var(--line);}
16639 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16640 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16641 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16642 .pagination-info{font-size:13px;color:var(--muted);}
16643 .pagination-btns{display:flex;gap:6px;}
16644 .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;}
16645 .pg-btn:hover:not(:disabled){background:var(--line);}
16646 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16647 .pg-btn:disabled{opacity:.35;cursor:default;}
16648 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16649 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16650 .site-footer a{color:var(--muted);}
16651 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16652 .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;}
16653 .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;}
16654 .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;}
16655 @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));}}
16656 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16657 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16658 .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;}
16659 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16660 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16661 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16662 .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);}
16663 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16664 .stat-chip:hover .stat-chip-tip{opacity:1;}
16665 .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;}
16666 .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;}
16667 .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%;}
16668 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16669 .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;}
16670 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16671 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16672 .hidden{display:none!important;}
16673 .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%;}
16674 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16675 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16676 .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;}
16677 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16678 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16679 .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;}
16680 .scope-option:hover{background:var(--line);}
16681 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16682 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16683 .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;}
16684 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16685 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16686 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16687 .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;}
16688 </style>
16689</head>
16690<body>
16691 <div class="background-watermarks" aria-hidden="true">
16692 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16693 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16694 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16695 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16696 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16697 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16698 </div>
16699 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16700 <div class="top-nav">
16701 <div class="top-nav-inner">
16702 <a class="brand" href="/">
16703 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16704 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16705 </a>
16706 <div class="nav-right">
16707 <a class="nav-pill" href="/">Home</a>
16708 <div class="nav-dropdown">
16709 <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>
16710 <div class="nav-dropdown-menu">
16711 <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>
16712 </div>
16713 </div>
16714 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16715 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16716 <div class="nav-dropdown">
16717 <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>
16718 <div class="nav-dropdown-menu">
16719 <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>
16720 </div>
16721 </div>
16722 <div class="server-status-wrap">
16723 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16724 <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>
16725 </div>
16726 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16727 <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>
16728 </button>
16729 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16730 <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>
16731 <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>
16732 </button>
16733 </div>
16734 </div>
16735 </div>
16736
16737 <div class="page">
16738 <div class="watched-bar">
16739 <div class="watched-bar-left">
16740 <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>
16741 <span class="watched-label">Watched Folders</span>
16742 <div class="watched-chips">
16743 {% for dir in watched_dirs %}
16744 <span class="watched-chip">
16745 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16746 <form method="POST" action="/watched-dirs/remove" style="display:contents">
16747 <input type="hidden" name="folder_path" value="{{ dir }}">
16748 <input type="hidden" name="redirect_to" value="/compare-scans">
16749 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
16750 </form>
16751 </span>
16752 {% endfor %}
16753 {% if watched_dirs.is_empty() %}
16754 <span class="watched-none">No folders watched — click Choose to add one</span>
16755 {% endif %}
16756 </div>
16757 </div>
16758 <div class="watched-bar-right">
16759 <button type="button" class="btn" id="add-watched-btn">
16760 <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>
16761 Choose
16762 </button>
16763 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16764 <input type="hidden" name="redirect_to" value="/compare-scans">
16765 <button type="submit" class="btn">↻ Refresh</button>
16766 </form>
16767 </div>
16768 </div>
16769 {% if total_scans > 0 %}
16770 <div class="summary-strip">
16771 <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>
16772 <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>
16773 <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>
16774 <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>
16775 </div>
16776 {% endif %}
16777 <section class="panel">
16778 <div class="panel-header">
16779 <div>
16780 <h1>Compare Scans</h1>
16781 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16782 </div>
16783 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16784 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16785 <button class="btn primary" id="compare-btn" disabled>
16786 <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>
16787 Compare <span class="sel-count" id="sel-count">0/2</span>
16788 </button>
16789 </div>
16790 </div>
16791 </div>
16792
16793 {% if entries.is_empty() %}
16794 <div class="empty-state">
16795 <strong>No scans yet</strong>
16796 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.
16797 </div>
16798 {% else %}
16799 <div class="filter-row">
16800 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16801 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16802 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
16803 </div>
16804 <div class="scope-panel hidden" id="scope-panel">
16805 <div class="scope-panel-label">
16806 <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>
16807 Compare scope — choose what to include
16808 </div>
16809 <div class="scope-options" id="scope-options"></div>
16810 </div>
16811 {% if total_scans > 0 %}
16812 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16813 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16814 <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>
16815 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16816 </div>
16817 </div>
16818 {% endif %}
16819 <div class="table-wrap">
16820 <table id="compare-table">
16821 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16822 <thead>
16823 <tr id="compare-thead">
16824 <th><div class="col-resize-handle"></div></th>
16825 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16826 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16827 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16828 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16829 <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>
16830 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16831 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16832 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16833 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
16834 <th>Submodules<div class="col-resize-handle"></div></th>
16835 </tr>
16836 </thead>
16837 <tbody id="compare-tbody">
16838 {% for entry in entries %}
16839 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16840 data-timestamp="{{ entry.timestamp }}"
16841 data-project="{{ entry.project_label }}"
16842 data-files="{{ entry.files_analyzed }}"
16843 data-code="{{ entry.code_lines }}"
16844 data-comments="{{ entry.comment_lines }}"
16845 data-blank="{{ entry.blank_lines }}"
16846 data-branch="{{ entry.git_branch }}"
16847 data-commit="{{ entry.git_commit }}"
16848 data-submodules="{{ entry.submodule_names_csv }}">
16849 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16850 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16851 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16852 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16853 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16854 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16855 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16856 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16857 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16858 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
16859 <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>
16860 </tr>
16861 {% endfor %}
16862 </tbody>
16863 </table>
16864 </div>
16865 <div class="pagination">
16866 <span class="pagination-info" id="pagination-info"></span>
16867 <div class="pagination-btns" id="pagination-btns"></div>
16868 <div class="flex-row">
16869 <span class="per-page-label">Show</span>
16870 <select class="per-page" id="per-page-sel">
16871 <option value="10">10 per page</option>
16872 <option value="25" selected>25 per page</option>
16873 <option value="50">50 per page</option>
16874 <option value="100">100 per page</option>
16875 </select>
16876 <span class="per-page-label" id="page-range-label"></span>
16877 </div>
16878 </div>
16879 {% endif %}
16880 </section>
16881 </div>
16882
16883 <footer class="site-footer">
16884 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
16885 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16886 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16887 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16888 · <a href="/api-docs" rel="noopener">REST API</a>
16889 </footer>
16890
16891 <script nonce="{{ csp_nonce }}">
16892 (function () {
16893 // ── Theme ──────────────────────────────────────────────────────────────
16894 var storageKey = 'oxide-sloc-theme';
16895 var body = document.body;
16896 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16897 var toggle = document.getElementById('theme-toggle');
16898 if (toggle) toggle.addEventListener('click', function () {
16899 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16900 body.classList.toggle('dark-theme', next === 'dark');
16901 try { localStorage.setItem(storageKey, next); } catch(e) {}
16902 });
16903
16904 // ── State ─────────────────────────────────────────────────────────────
16905 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16906 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16907 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16908
16909 // ── Stat chips ────────────────────────────────────────────────────────
16910 (function() {
16911 var projects = {}, latestTs = '', latestRow = null;
16912 allRows.forEach(function(r) {
16913 var p = r.dataset.project || ''; if (p) projects[p] = true;
16914 var ts = r.dataset.timestamp || '';
16915 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16916 });
16917 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();}
16918 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>':'');}
16919 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16920 if (latestRow) {
16921 setChipVal('agg-code', latestRow.dataset.code);
16922 setChipVal('agg-files', latestRow.dataset.files);
16923 }
16924 })();
16925
16926 // ── Branch filter population ──────────────────────────────────────────
16927 (function() {
16928 var branches = {};
16929 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16930 var sel = document.getElementById('branch-filter');
16931 if (sel) Object.keys(branches).sort().forEach(function(b) {
16932 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16933 });
16934 })();
16935
16936 // ── Filter ────────────────────────────────────────────────────────────
16937 function getFilteredRows() {
16938 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16939 var branch = ((document.getElementById('branch-filter') || {}).value || '');
16940 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16941 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16942 if (branch && (r.dataset.branch || '') !== branch) return false;
16943 return true;
16944 });
16945 }
16946
16947 // ── Pagination ────────────────────────────────────────────────────────
16948 function renderPage() {
16949 var filtered = getFilteredRows();
16950 var total = filtered.length;
16951 var totalPages = Math.max(1, Math.ceil(total / perPage));
16952 currentPage = Math.min(currentPage, totalPages);
16953 var start = (currentPage - 1) * perPage;
16954 var end = Math.min(start + perPage, total);
16955 var shown = {};
16956 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16957 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16958 r.style.display = shown[r.dataset.run] ? '' : 'none';
16959 });
16960 var rl = document.getElementById('page-range-label');
16961 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16962 var info = document.getElementById('pagination-info');
16963 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16964 var btns = document.getElementById('pagination-btns');
16965 if (!btns) return;
16966 btns.innerHTML = '';
16967 function makeBtn(lbl, pg, active, disabled) {
16968 var b = document.createElement('button');
16969 b.className = 'pg-btn' + (active ? ' active' : '');
16970 b.textContent = lbl; b.disabled = disabled;
16971 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16972 return b;
16973 }
16974 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16975 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16976 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16977 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16978 }
16979
16980 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16981 window.applyFilters = function() { currentPage = 1; renderPage(); };
16982
16983 // ── Sorting ───────────────────────────────────────────────────────────
16984 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16985 function doSort(col, type, order) {
16986 var tbody = document.getElementById('compare-tbody');
16987 if (!tbody) return;
16988 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16989 rows.sort(function(a, b) {
16990 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16991 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16992 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16993 return va < vb ? 1 : va > vb ? -1 : 0;
16994 });
16995 rows.forEach(function(r) { tbody.appendChild(r); });
16996 currentPage = 1; renderPage();
16997 }
16998 sortHeaders.forEach(function(th) {
16999 th.addEventListener('click', function(e) {
17000 if (e.target.classList.contains('col-resize-handle')) return;
17001 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17002 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17003 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17004 th.classList.add('sort-' + sortOrder);
17005 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17006 doSort(col, type, sortOrder);
17007 });
17008 });
17009
17010 // Apply default sort (timestamp desc) on initial load
17011 (function() {
17012 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
17013 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
17014 })();
17015
17016 // ── Column resize ─────────────────────────────────────────────────────
17017 (function() {
17018 var table = document.getElementById('compare-table');
17019 if (!table) return;
17020 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17021 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
17022 ths.forEach(function(th, i) {
17023 var handle = th.querySelector('.col-resize-handle');
17024 if (!handle || !cols[i]) return;
17025 var startX, startW;
17026 handle.addEventListener('mousedown', function(e) {
17027 e.stopPropagation(); e.preventDefault();
17028 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17029 handle.classList.add('dragging');
17030 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17031 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17032 document.addEventListener('mousemove', onMove);
17033 document.addEventListener('mouseup', onUp);
17034 });
17035 });
17036 })();
17037
17038 // ── Reset view ────────────────────────────────────────────────────────
17039 window.resetView = function() {
17040 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
17041 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
17042 sortCol = null; sortOrder = 'asc';
17043 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17044 var tbody = document.getElementById('compare-tbody');
17045 if (tbody) {
17046 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
17047 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17048 rows.forEach(function(r) { tbody.appendChild(r); });
17049 }
17050 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
17051 var table = document.getElementById('compare-table');
17052 currentPage = 1; renderPage();
17053 currentPage = 1; renderPage();
17054 };
17055
17056 renderPage();
17057
17058 // ── Row selection state ───────────────────────────────────────────────
17059 var selected = [];
17060 function updateCompareBtn() {
17061 var btn = document.getElementById('compare-btn');
17062 var cnt = document.getElementById('sel-count');
17063 if (!btn) return;
17064 btn.disabled = selected.length !== 2;
17065 if (cnt) cnt.textContent = selected.length + '/2';
17066 }
17067
17068 function toggleRow(row) {
17069 var vid = row.dataset.vid || row.dataset.run;
17070 var idx = selected.indexOf(vid);
17071 if (idx >= 0) {
17072 selected.splice(idx, 1);
17073 row.classList.remove('selected');
17074 var b = document.getElementById('badge-' + vid);
17075 if (b) b.textContent = '';
17076 } else {
17077 if (selected.length >= 2) return;
17078 selected.push(vid);
17079 row.classList.add('selected');
17080 }
17081 selected.forEach(function(v, i) {
17082 var b = document.getElementById('badge-' + v);
17083 if (b) b.textContent = i + 1;
17084 });
17085 updateCompareBtn();
17086 buildScopePanel();
17087 }
17088
17089 // ── Scope panel ───────────────────────────────────────────────────────
17090 var selectedScope = 'all';
17091
17092 function buildScopePanel() {
17093 var panel = document.getElementById('scope-panel');
17094 var opts = document.getElementById('scope-options');
17095 if (!panel || !opts) return;
17096 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
17097
17098 // Collect union of submodules from both selected rows.
17099 var allSubs = {};
17100 selected.forEach(function(vid) {
17101 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
17102 if (!row) return;
17103 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
17104 });
17105 var subList = Object.keys(allSubs).sort();
17106 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
17107
17108 panel.classList.remove('hidden');
17109 opts.innerHTML = '';
17110
17111 function makeOption(value, label, title) {
17112 var div = document.createElement('div');
17113 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
17114 div.dataset.scopeValue = value;
17115 if (title) div.title = title;
17116 var radio = document.createElement('span');
17117 radio.className = 'scope-option-radio';
17118 var lbl = document.createElement('span');
17119 lbl.textContent = label;
17120 div.appendChild(radio);
17121 div.appendChild(lbl);
17122 div.addEventListener('click', function() {
17123 selectedScope = value;
17124 opts.querySelectorAll('.scope-option').forEach(function(o) {
17125 o.classList.toggle('selected', o.dataset.scopeValue === value);
17126 });
17127 });
17128 return div;
17129 }
17130
17131 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
17132 var sep = document.createElement('span');
17133 sep.className = 'scope-option-sep';
17134 opts.appendChild(sep);
17135 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
17136 subList.forEach(function(s) {
17137 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
17138 });
17139 }
17140
17141 function doCompare() {
17142 if (selected.length !== 2) return;
17143 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
17144 if (selectedScope === 'super') url += '&scope=super';
17145 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
17146 window.location.href = url;
17147 }
17148
17149 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
17150 var cbtn = document.getElementById('compare-btn');
17151 if (cbtn) cbtn.addEventListener('click', doCompare);
17152 var pfEl = document.getElementById('project-filter');
17153 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
17154 var bfEl = document.getElementById('branch-filter');
17155 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
17156 var rvBtn = document.getElementById('reset-view-btn');
17157 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
17158 var ppSel = document.getElementById('per-page-sel');
17159 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
17160
17161 var cmpTbody = document.getElementById('compare-tbody');
17162 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
17163 var row = e.target.closest('.compare-row');
17164 if (row) toggleRow(row);
17165 });
17166
17167 (function randomizeWatermarks() {
17168 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17169 if (!wms.length) return;
17170 var placed = [];
17171 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;}
17172 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];}
17173 var half=Math.floor(wms.length/2);
17174 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;});
17175 })();
17176
17177 (function spawnCodeParticles() {
17178 var container = document.getElementById('code-particles');
17179 if (!container) return;
17180 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'];
17181 for (var i = 0; i < 38; i++) {
17182 (function(idx) {
17183 var el = document.createElement('span');
17184 el.className = 'code-particle';
17185 el.textContent = snippets[idx % snippets.length];
17186 var left = Math.random() * 94 + 2;
17187 var top = Math.random() * 88 + 6;
17188 var dur = (Math.random() * 10 + 9).toFixed(1);
17189 var delay = (Math.random() * 18).toFixed(1);
17190 var rot = (Math.random() * 26 - 13).toFixed(1);
17191 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17192 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';
17193 container.appendChild(el);
17194 })(i);
17195 }
17196 })();
17197
17198 // ── Watched folder picker ─────────────────────────────────────────────
17199 (function() {
17200 var btn = document.getElementById('add-watched-btn');
17201 if (!btn) return;
17202 btn.addEventListener('click', function() {
17203 fetch('/pick-directory?kind=reports')
17204 .then(function(r) { return r.json(); })
17205 .then(function(data) {
17206 if (!data.cancelled && data.selected_path) {
17207 var form = document.createElement('form');
17208 form.method = 'POST';
17209 form.action = '/watched-dirs/add';
17210 var ri = document.createElement('input');
17211 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17212 var fi = document.createElement('input');
17213 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17214 form.appendChild(ri); form.appendChild(fi);
17215 document.body.appendChild(form);
17216 form.submit();
17217 }
17218 })
17219 .catch(function(e) { alert('Could not open folder picker: ' + e); });
17220 });
17221 })();
17222
17223 // ── Submodule chip truncation ─────────────────────────────────────────
17224 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17225 var chips = cell.querySelectorAll('.submod-chip');
17226 var MAX = 4;
17227 if (chips.length <= MAX) return;
17228 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17229 var badge = document.createElement('span');
17230 badge.className = 'submod-overflow-badge';
17231 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17232 badge.textContent = '+' + (chips.length - MAX) + ' more';
17233 cell.appendChild(badge);
17234 cell.style.maxHeight = 'none';
17235 });
17236 })();
17237 </script>
17238 <script nonce="{{ csp_nonce }}">
17239 (function(){
17240 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'}];
17241 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);});}
17242 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17243 function init(){
17244 var btn=document.getElementById('settings-btn');if(!btn)return;
17245 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17246 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>';
17247 document.body.appendChild(m);
17248 var g=document.getElementById('scheme-grid');
17249 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);});
17250 var cl=document.getElementById('settings-close');
17251 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);
17252 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');});
17253 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17254 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17255 }
17256 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17257 }());
17258 </script>
17259</body>
17260</html>
17261"##,
17262 ext = "html"
17263)]
17264struct CompareSelectTemplate {
17265 version: &'static str,
17266 entries: Vec<HistoryEntryRow>,
17267 total_scans: usize,
17268 watched_dirs: Vec<String>,
17269 csp_nonce: String,
17270}
17271
17272#[derive(Template)]
17275#[template(
17276 source = r##"
17277<!doctype html>
17278<html lang="en">
17279<head>
17280 <meta charset="utf-8">
17281 <meta name="viewport" content="width=device-width, initial-scale=1">
17282 <title>OxideSLOC | Scan Delta</title>
17283 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17284 <style nonce="{{ csp_nonce }}">
17285 :root {
17286 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17287 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17288 --nav:#283790; --nav-2:#013e6b;
17289 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17290 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17291 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17292 }
17293 body.dark-theme {
17294 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17295 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17296 }
17297 *{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);}
17298 .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);}
17299 .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;}
17300 .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));}
17301 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17302 .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;}
17303 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17304 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17305 @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; } }
17306 .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;}
17307 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17308 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17309 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17310 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17311 .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;}
17312 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17313 .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);}
17314 .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;}
17315 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17316 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17317 .settings-modal-body{padding:14px 16px 16px;}
17318 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17319 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17320 .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;}
17321 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17322 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17323 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17324 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17325 .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;}
17326 .tz-select:focus{border-color:var(--oxide);}
17327 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17328 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17329 .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;}
17330 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17331 .hero-body{display:block;}
17332 .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;}
17333 .btn-back:hover{background:var(--line);}
17334 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17335 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17336 .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;}
17337 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17338 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;}
17339 .muted{color:var(--muted);font-size:14px;}
17340 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17341 .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;}
17342 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17343 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17344 .vpill-arrow{font-size:20px;color:var(--muted);}
17345 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17346 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17347 .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;}
17348 .delta-card.delta-card-wide{padding:22px 24px;}
17349 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17350 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17351 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17352 .delta-card-from{font-size:15px;color:var(--muted);}
17353 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17354 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17355 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17356 .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%;}
17357 .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;}
17358 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17359 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17360 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17361 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17362 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17363 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17364 .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;}
17365 .meta-card-commit:hover{color:var(--oxide);}
17366 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17367 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17368 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17369 .meta-value{color:var(--text);font-size:13px;}
17370 .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;}
17371 .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);}
17372 .delta-card:hover .dc-tip{display:block;}
17373 .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;}
17374 .export-btn:hover{background:var(--line);}
17375 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17376 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17377 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17378 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17379 .delta-card-change.zero{color:var(--muted);background:transparent;}
17380 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17381 .delta-card-pct.pos{color:var(--pos);}
17382 .delta-card-pct.neg{color:var(--neg);}
17383 .delta-card-pct.zero{color:var(--muted);}
17384 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17385 .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;}
17386 .insight-card.insight-flag{border-color:var(--oxide);}
17387 .insight-card:hover .dc-tip{display:block;}
17388 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17389 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17390 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17391 .insight-label.flag{color:var(--oxide);}
17392 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17393 .insight-val.pos{color:var(--pos);}
17394 .insight-val.neg{color:var(--neg);}
17395 .insight-val.high{color:#c0392a;}
17396 .insight-val.med{color:#926000;}
17397 .insight-val.low{color:var(--pos);}
17398 body.dark-theme .insight-val.high{color:#ff6b6b;}
17399 body.dark-theme .insight-val.med{color:#f0c060;}
17400 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17401 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17402 .fc-row{display:flex;align-items:center;gap:8px;}
17403 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17404 .fc-label{color:var(--muted);}
17405 .fc-modified .fc-count{color:#926000;}
17406 .fc-added .fc-count{color:var(--pos);}
17407 .fc-removed .fc-count{color:var(--neg);}
17408 .fc-unchanged .fc-count{color:var(--muted);}
17409 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17410 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17411 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17412 .chip.modified{background:#fff2d8;color:#926000;}
17413 .chip.added{background:#e8f5ed;color:#1a8f47;}
17414 .chip.removed{background:#fdeaea;color:#b33b3b;}
17415 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17416 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17417 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17418 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17419 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17420 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17421 .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;}
17422 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17423 .tab-btn:hover:not(.active){background:var(--line);}
17424 .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;}
17425 .btn-reset:hover{background:var(--line);}
17426 .table-wrap{width:100%;overflow-x:auto;}
17427 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17428 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;}
17429 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17430 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17431 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17432 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17433 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17434 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17435 tr:last-child td{border-bottom:none;}
17436 tr.row-added td{background:rgba(26,143,71,0.06);}
17437 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17438 tr.row-modified td{background:rgba(146,96,0,0.05);}
17439 tr.row-unchanged td{opacity:.6;}
17440 .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17441 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17442 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17443 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17444 .status-badge.modified{background:#fff2d8;color:#926000;}
17445 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17446 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17447 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17448 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17449 .delta-val{font-weight:700;}
17450 .delta-val.pos{color:var(--pos);}
17451 .delta-val.neg{color:var(--neg);}
17452 .delta-val.zero{color:var(--muted);}
17453 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17454 .from-to strong{color:var(--text);}
17455 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17456 .site-footer a{color:var(--muted);}
17457 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17458 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17459 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17460 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17461 .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;}
17462 .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;}
17463 .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;}
17464 @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));}}
17465 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17466 .path-link:hover{color:var(--oxide-2);}
17467 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17468 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17469 a.vpill-id:hover{color:var(--oxide);}
17470 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17471 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17472 .pagination-info{font-size:13px;color:var(--muted);}
17473 .pagination-btns{display:flex;gap:6px;}
17474 .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;}
17475 .pg-btn:hover:not(:disabled){background:var(--line);}
17476 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17477 .pg-btn:disabled{opacity:.35;cursor:default;}
17478 .per-page-label{font-size:13px;color:var(--muted);}
17479 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;}
17480 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17481 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17482 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17483 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17484 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17485 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17486 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17487 .tab-btn.tab-unchanged{color:var(--muted);}
17488 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17489 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17490 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17491 .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;}
17492 .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;}
17493 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17494 .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;}
17495 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17496 .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;}
17497 .submod-scope-btn:hover{background:var(--line);}
17498 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17499 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17500 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17501 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17502 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17503 body.dark-theme .ic-card{background:var(--surface-2);}
17504 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17505 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17506 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17507 .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17508 #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;}
17509 </style>
17510</head>
17511<body>
17512 <div class="background-watermarks" aria-hidden="true">
17513 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17514 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17515 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17516 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17517 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17518 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17519 </div>
17520 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17521 <div class="top-nav">
17522 <div class="top-nav-inner">
17523 <a class="brand" href="/">
17524 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17525 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17526 </a>
17527 <div class="nav-right">
17528 <a class="nav-pill" href="/">Home</a>
17529 <div class="nav-dropdown">
17530 <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>
17531 <div class="nav-dropdown-menu">
17532 <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>
17533 </div>
17534 </div>
17535 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17536 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17537 <div class="nav-dropdown">
17538 <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>
17539 <div class="nav-dropdown-menu">
17540 <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>
17541 </div>
17542 </div>
17543 <div class="server-status-wrap">
17544 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17545 <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>
17546 </div>
17547 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17548 <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>
17549 </button>
17550 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17551 <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>
17552 <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>
17553 </button>
17554 </div>
17555 </div>
17556 </div>
17557
17558 <div class="page">
17559 <section class="hero">
17560 <div class="hero-header">
17561 <div>
17562 <h1 class="delta-title">Scan Delta</h1>
17563 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17564 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17565 {% if let Some(sub) = active_submodule %}
17566 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17567 {% else if super_scope_active %}
17568 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17569 {% else %}
17570 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17571 {% endif %}
17572 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17573 </div>
17574 </div>
17575 <a class="btn-back" href="/compare-scans">
17576 <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>
17577 Compare Scans
17578 </a>
17579 </div>
17580 {% if has_any_submodule_data %}
17581 <div class="submod-scope-bar">
17582 <span class="submod-scope-label">
17583 <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>
17584 Scope:
17585 </span>
17586 <div class="submod-scope-divider"></div>
17587 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17588 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
17589 title="All files — super-repo and all submodules combined">Full scan</a>
17590 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17591 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
17592 title="Only files that are not part of any submodule">Super-repo only</a>
17593 {% for sub in submodule_options %}
17594 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17595 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
17596 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17597 {% endfor %}
17598 </div>
17599 {% endif %}
17600 <div class="hero-body">
17601 <div class="meta-strip">
17602 <div class="delta-card delta-card-meta">
17603 <div class="meta-card-header">
17604 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17605 <div class="meta-card-project-col">
17606 <div class="meta-card-project">{{ project_name }}</div>
17607 {% if has_any_submodule_data %}
17608 {% if let Some(sub) = active_submodule %}
17609 <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>
17610 {% else if super_scope_active %}
17611 <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>
17612 {% else %}
17613 <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>
17614 {% endif %}
17615 {% endif %}
17616 </div>
17617 </div>
17618 {% if !baseline_git_commit.is_empty() %}
17619 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17620 {% else %}
17621 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17622 {% endif %}
17623 <div class="meta-card-rows">
17624 <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>
17625 <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>
17626 <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>
17627 <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>
17628 {% if let Some(tags) = baseline_git_tags %}
17629 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17630 {% endif %}
17631 </div>
17632 </div>
17633 <div class="delta-card delta-card-meta">
17634 <div class="meta-card-header">
17635 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17636 <div class="meta-card-project-col">
17637 <div class="meta-card-project">{{ project_name }}</div>
17638 {% if has_any_submodule_data %}
17639 {% if let Some(sub) = active_submodule %}
17640 <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>
17641 {% else if super_scope_active %}
17642 <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>
17643 {% else %}
17644 <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>
17645 {% endif %}
17646 {% endif %}
17647 </div>
17648 </div>
17649 {% if !current_git_commit.is_empty() %}
17650 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17651 {% else %}
17652 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17653 {% endif %}
17654 <div class="meta-card-rows">
17655 <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>
17656 <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>
17657 <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>
17658 <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>
17659 {% if let Some(tags) = current_git_tags %}
17660 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17661 {% endif %}
17662 </div>
17663 </div>
17664 </div>
17665 <div class="delta-strip">
17666 <div class="delta-card">
17667 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17668 <div class="delta-card-label">Code lines</div>
17669 <div class="delta-card-from">Before: {{ baseline_code }}</div>
17670 <div class="delta-card-to">{{ current_code }}</div>
17671 {% 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>
17672 {% 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>
17673 {% else %}<div class="delta-card-pct zero">±0%</div>
17674 {% endif %}
17675 </div>
17676 <div class="delta-card">
17677 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17678 <div class="delta-card-label">Files analyzed</div>
17679 <div class="delta-card-from">Before: {{ baseline_files }}</div>
17680 <div class="delta-card-to">{{ current_files }}</div>
17681 {% 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>
17682 {% 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>
17683 {% else %}<div class="delta-card-pct zero">±0%</div>
17684 {% endif %}
17685 </div>
17686 <div class="delta-card">
17687 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17688 <div class="delta-card-label">Comment lines</div>
17689 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17690 <div class="delta-card-to">{{ current_comments }}</div>
17691 {% 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>
17692 {% 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>
17693 {% else %}<div class="delta-card-pct zero">±0%</div>
17694 {% endif %}
17695 </div>
17696 <div class="delta-card delta-card-wide">
17697 <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>
17698 <div class="delta-card-label">File changes</div>
17699 <div class="file-changes-grid">
17700 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17701 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17702 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17703 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17704 </div>
17705 </div>
17706 </div>
17707 <div class="insights-panel">
17708 <div class="insight-card">
17709 <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>
17710 <div class="insight-label">Lines Added</div>
17711 <div class="insight-val pos">+{{ code_lines_added }}</div>
17712 <div class="insight-sub">New or grown source lines</div>
17713 </div>
17714 <div class="insight-card">
17715 <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>
17716 <div class="insight-label">Lines Removed</div>
17717 <div class="insight-val neg">−{{ code_lines_removed }}</div>
17718 <div class="insight-sub">Deleted or shrunk source lines</div>
17719 </div>
17720 <div class="insight-card">
17721 <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>
17722 <div class="insight-label">Churn Rate</div>
17723 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17724 <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>
17725 </div>
17726 {% if scope_flag %}
17727 <div class="insight-card insight-flag">
17728 <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>
17729 <div class="insight-label flag">Scope Signal</div>
17730 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17731 <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>
17732 </div>
17733 {% endif %}
17734 </div>
17735 </div>
17736 </section>
17737
17738 <section class="panel" id="inline-charts-section">
17739 <h2>Scan Delta Charts</h2>
17740 <div class="ic-grid">
17741 <div class="ic-card">
17742 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
17743 <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>
17744 <div id="ic-c1"></div>
17745 </div>
17746 <div class="ic-card" id="ic-lang-card">
17747 <div class="ic-card-h2">Language Code Delta</div>
17748 <div id="ic-c3"></div>
17749 </div>
17750 <div class="ic-card">
17751 <div class="ic-card-h2">Delta by Metric</div>
17752 <div id="ic-c2"></div>
17753 </div>
17754 <div class="ic-card">
17755 <div class="ic-card-h2">File Change Distribution</div>
17756 <div id="ic-c4"></div>
17757 </div>
17758 </div>
17759 </section>
17760
17761 <section class="panel">
17762 <h2>File-level delta</h2>
17763 <div class="filter-tabs-row">
17764 <div class="filter-tabs">
17765 <button class="tab-btn tab-all active" data-filter="all">All</button>
17766 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17767 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17768 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17769 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17770 </div>
17771 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17772 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
17773 <div class="export-group">
17774 <button type="button" class="btn-reset" id="delta-reset-btn">↻ Reset</button>
17775 <button type="button" class="export-btn" id="delta-csv-btn">
17776 <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>
17777 CSV
17778 </button>
17779 <button type="button" class="export-btn" id="delta-xls-btn">
17780 <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>
17781 Excel
17782 </button>
17783 <button type="button" class="export-btn" id="delta-charts-btn">
17784 <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>
17785 Charts
17786 </button>
17787 </div>
17788 </div>
17789 </div>
17790
17791 <div class="table-wrap">
17792 <table id="delta-table">
17793 <colgroup>
17794 <col>
17795 <col>
17796 <col>
17797 <col>
17798 <col>
17799 <col>
17800 <col>
17801 </colgroup>
17802 <thead>
17803 <tr id="delta-thead">
17804 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17805 <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>
17806 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
17807 <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>
17808 <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>
17809 <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>
17810 <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>
17811 </tr>
17812 </thead>
17813 <tbody id="delta-tbody">
17814 {% for row in file_rows %}
17815 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17816 data-path="{{ row.relative_path }}"
17817 data-language="{{ row.language }}"
17818 data-baseline-code="{{ row.baseline_code }}"
17819 data-current-code="{{ row.current_code }}"
17820 data-code-delta="{{ row.code_delta_str }}"
17821 data-comment-delta="{{ row.comment_delta_str }}"
17822 data-total-delta="{{ row.total_delta_str }}"
17823 data-orig-idx="">
17824 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17825 <td class="hide-sm">{{ row.language }}</td>
17826 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17827 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17828 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17829 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17830 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17831 </tr>
17832 {% endfor %}
17833 </tbody>
17834 </table>
17835 </div>
17836 <div class="pagination">
17837 <span class="pagination-info" id="pg-info"></span>
17838 <div class="pagination-btns" id="pg-btns"></div>
17839 <div class="flex-row">
17840 <span class="per-page-label">Show</span>
17841 <select class="per-page" id="per-page-sel">
17842 <option value="10">10 per page</option>
17843 <option value="25" selected>25 per page</option>
17844 <option value="50">50 per page</option>
17845 <option value="100">100 per page</option>
17846 </select>
17847 <span class="per-page-label" id="pg-range-label"></span>
17848 </div>
17849 </div>
17850 </section>
17851 </div>
17852
17853 <div id="ic-tt"></div>
17854
17855 <footer class="site-footer">
17856 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
17857 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17858 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17859 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17860 · <a href="/api-docs" rel="noopener">REST API</a>
17861 </footer>
17862
17863 <script nonce="{{ csp_nonce }}">
17864 (function () {
17865 var storageKey = 'oxide-sloc-theme';
17866 var body = document.body;
17867 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17868 var toggle = document.getElementById('theme-toggle');
17869 if (toggle) toggle.addEventListener('click', function () {
17870 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17871 body.classList.toggle('dark-theme', next === 'dark');
17872 try { localStorage.setItem(storageKey, next); } catch(e) {}
17873 });
17874
17875 (function randomizeWatermarks() {
17876 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17877 if (!wms.length) return;
17878 var placed = [];
17879 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;}
17880 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];}
17881 var half=Math.floor(wms.length/2);
17882 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;});
17883 })();
17884
17885 (function spawnCodeParticles() {
17886 var container = document.getElementById('code-particles');
17887 if (!container) return;
17888 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'];
17889 for (var i = 0; i < 38; i++) {
17890 (function(idx) {
17891 var el = document.createElement('span');
17892 el.className = 'code-particle';
17893 el.textContent = snippets[idx % snippets.length];
17894 var left = Math.random() * 94 + 2;
17895 var top = Math.random() * 88 + 6;
17896 var dur = (Math.random() * 10 + 9).toFixed(1);
17897 var delay = (Math.random() * 18).toFixed(1);
17898 var rot = (Math.random() * 26 - 13).toFixed(1);
17899 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17900 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';
17901 container.appendChild(el);
17902 })(i);
17903 }
17904 })();
17905 })();
17906
17907 var activeStatusFilter = 'all';
17908 var deltaPerPage = 25, deltaCurrPage = 1;
17909
17910 function openFolder(path) {
17911 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17912 }
17913
17914 function getDeltaFilteredRows() {
17915 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17916 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17917 });
17918 }
17919
17920 function renderDeltaPage() {
17921 var filtered = getDeltaFilteredRows();
17922 var total = filtered.length;
17923 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17924 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17925 var start = (deltaCurrPage - 1) * deltaPerPage;
17926 var end = Math.min(start + deltaPerPage, total);
17927 var shownSet = {};
17928 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17929 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17930 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17931 });
17932 var rl = document.getElementById('pg-range-label');
17933 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17934 var info = document.getElementById('pg-info');
17935 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17936 var btns = document.getElementById('pg-btns');
17937 if (!btns) return;
17938 btns.innerHTML = '';
17939 if (totalPages <= 1) return;
17940 function makeBtn(lbl, pg, active, disabled) {
17941 var b = document.createElement('button');
17942 b.className = 'pg-btn' + (active ? ' active' : '');
17943 b.textContent = lbl; b.disabled = disabled;
17944 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17945 return b;
17946 }
17947 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17948 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17949 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17950 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17951 }
17952
17953 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17954
17955 function filterRows(status, btn) {
17956 activeStatusFilter = status;
17957 deltaCurrPage = 1;
17958 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17959 b.classList.remove('active');
17960 });
17961 if (btn) btn.classList.add('active');
17962 renderDeltaPage();
17963 }
17964
17965 // ── Sorting ──────────────────────────────────────────────────────────────
17966 var sortCol = null, sortOrder = 'asc';
17967 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17968 (function() {
17969 var tbody = document.getElementById('delta-tbody');
17970 if (!tbody) return;
17971 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17972 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17973 })();
17974
17975 function parseDeltaNum(str) {
17976 if (!str || str === '—') return 0;
17977 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17978 }
17979
17980 sortHeaders.forEach(function(th) {
17981 th.addEventListener('click', function(e) {
17982 if (e.target.classList.contains('col-resize-handle')) return;
17983 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17984 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17985 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17986 th.classList.add('sort-' + sortOrder);
17987 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17988 var tbody = document.getElementById('delta-tbody');
17989 if (!tbody) return;
17990 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17991 rows.sort(function(a, b) {
17992 var va, vb;
17993 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17994 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17995 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17996 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17997 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17998 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17999 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
18000 else { va = ''; vb = ''; }
18001 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18002 return va < vb ? 1 : va > vb ? -1 : 0;
18003 });
18004 rows.forEach(function(r) { tbody.appendChild(r); });
18005 deltaCurrPage = 1;
18006 renderDeltaPage();
18007 var activeBtn = document.querySelector('.tab-btn.active');
18008 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
18009 if (activeBtn) activeBtn.classList.add('active');
18010 });
18011 });
18012
18013 // ── Column resize ─────────────────────────────────────────────────────────
18014 (function() {
18015 var table = document.getElementById('delta-table');
18016 if (!table) return;
18017 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18018 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
18019 ths.forEach(function(th, i) {
18020 var handle = th.querySelector('.col-resize-handle');
18021 if (!handle || !cols[i]) return;
18022 var startX, startW;
18023 handle.addEventListener('mousedown', function(e) {
18024 e.stopPropagation(); e.preventDefault();
18025 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18026 handle.classList.add('dragging');
18027 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18028 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18029 document.addEventListener('mousemove', onMove);
18030 document.addEventListener('mouseup', onUp);
18031 });
18032 });
18033 })();
18034
18035 // ── Reset ─────────────────────────────────────────────────────────────────
18036 window.resetDeltaTable = function() {
18037 sortCol = null; sortOrder = 'asc';
18038 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18039 var tbody = document.getElementById('delta-tbody');
18040 if (tbody) {
18041 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
18042 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18043 rows.forEach(function(r) { tbody.appendChild(r); });
18044 }
18045 var table = document.getElementById('delta-table');
18046 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18047 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
18048 activeStatusFilter = 'all';
18049 deltaCurrPage = 1;
18050 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
18051 var allBtn = document.querySelector('.tab-btn');
18052 if (allBtn) allBtn.classList.add('active');
18053 renderDeltaPage();
18054 };
18055
18056 renderDeltaPage();
18057
18058 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
18059 (function() {
18060 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
18061 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
18062 });
18063 var resetBtn = document.getElementById('delta-reset-btn');
18064 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
18065 var csvBtn = document.getElementById('delta-csv-btn');
18066 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
18067 var xlsBtn = document.getElementById('delta-xls-btn');
18068 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
18069 var chartsBtn = document.getElementById('delta-charts-btn');
18070 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
18071 var ppSel = document.getElementById('per-page-sel');
18072 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
18073 var pathLink = document.getElementById('project-path-link');
18074 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
18075 })();
18076
18077 // ── Export helpers ────────────────────────────────────────────────────────
18078 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
18079 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18080 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);}
18081 function slocMakeXlsx(fname,sd,dr){
18082 var enc=new TextEncoder();
18083 // CRC-32 table
18084 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;}
18085 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;}
18086 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18087 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18088 // Shared string table
18089 var ss=[],si={};
18090 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
18091 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18092 // Worksheet builder — each WS() call gets its own row counter R
18093 function WS(){
18094 var R=0,buf=[];
18095 function cl(c){return String.fromCharCode(65+c);}
18096 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
18097 '<v>'+S(v)+'</v></c>';}
18098 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
18099 (st?' s="'+st+'"':'')+'>'+
18100 '<v>'+(+v)+'</v></c>';}
18101 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
18102 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
18103 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
18104 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
18105 '<sheetFormatPr defaultRowHeight="15"/>'+
18106 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
18107 return{sc:sc,nc:nc,row:row,xml:xml};
18108 }
18109 // Language breakdown
18110 var lm={};
18111 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;});
18112 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
18113 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
18114 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
18115 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
18116 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18117 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18118 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):'';}
18119 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
18120 // Summary sheet
18121 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
18122 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
18123 r1(s1(0,proj,2));
18124 r1(s1(0,sd.bts+' → '+sd.cts,2));
18125 r1('');
18126 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
18127 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))));
18128 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))));
18129 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))));
18130 r1('');
18131 r1(s1(0,'FILE CHANGES',8));
18132 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
18133 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
18134 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
18135 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
18136 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
18137 if(langs.length){
18138 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
18139 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
18140 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)));});
18141 }
18142 r1('');r1(s1(0,'SCAN METADATA',8));
18143 r1(s1(1,_blabel)+s1(2,_clabel));
18144 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
18145 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
18146 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"/>');
18147 // File Delta sheet
18148 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
18149 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));
18150 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)));});
18151 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
18152 // Shared strings XML
18153 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
18154 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
18155 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
18156 // XLSX file map
18157 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18158 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>',
18159 '_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>',
18160 '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>',
18161 '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>',
18162 '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>',
18163 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
18164 // ZIP packer — STORED (no compression), compatible with all XLSX readers
18165 var zparts=[],zcds=[],zoff=0,znf=0;
18166 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
18167 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
18168 ].forEach(function(name){
18169 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18170 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]);
18171 var entry=new Uint8Array(lha.length+nb.length+sz);
18172 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18173 zparts.push(entry);
18174 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));
18175 var cde=new Uint8Array(cda.length+nb.length);
18176 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18177 zcds.push(cde);zoff+=entry.length;znf++;
18178 });
18179 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18180 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]);
18181 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18182 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18183 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18184 zout.set(new Uint8Array(ea),zpos);
18185 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
18186 var xurl=URL.createObjectURL(xblob);
18187 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
18188 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
18189 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
18190 }
18191 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;');}
18192 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
18193 function getExportFilename(ext){return _exportBase+'.'+ext;}
18194
18195 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 }}'};
18196 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;}
18197 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
18198 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
18199 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18200 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18201 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):'';}
18202 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
18203 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)]];}
18204 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
18205 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;}
18206 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
18207 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
18208
18209 // ── Chart HTML report ─────────────────────────────────────────────────────
18210 function slocChartReport(fname, sd, dr) {
18211 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
18212 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18213 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18214 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();}
18215 function px(n){return Math.round(n);}
18216 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
18217 // Language map
18218 var lm={};
18219 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;});
18220 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18221
18222 // Builds onmouse* attrs for interactive tooltip on each SVG element
18223 function barTT(label,val){
18224 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18225 }
18226
18227 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18228 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'}];
18229 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18230 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18231 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18232 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18233 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"/>';}
18234 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18235 c1mets.forEach(function(m,i){
18236 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18237 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18238 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>';
18239 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))+'/>';
18240 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>';
18241 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))+'/>';
18242 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>';
18243 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>';
18244 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>';
18245 });
18246 c1+='</svg>';
18247
18248 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18249 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'}];
18250 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18251 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18252 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18253 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18254 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18255 mets.forEach(function(m,i){
18256 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18257 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18258 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18259 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>';
18260 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18261 if(bw>=52){
18262 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>';
18263 }else{
18264 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18265 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>';
18266 }
18267 });
18268 c2+='</svg>';
18269
18270 // ── Chart 3: Language Code Delta ─────────────────────────────────────
18271 var c3='';
18272 if(langs.length){
18273 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18274 var C3W=550,c3LW=124,c3FW=52;
18275 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18276 var L3rH=30,C3H=langs.length*L3rH+20;
18277 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18278 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18279 langs.forEach(function(l,i){
18280 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18281 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18282 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18283 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18284 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':''))+'/>';
18285 if(bw>=48){
18286 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>';
18287 }else{
18288 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18289 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>';
18290 }
18291 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>';
18292 });
18293 c3+='</svg>';
18294 }
18295
18296 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18297 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;});
18298 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18299 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18300 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18301 var ang=-Math.PI/2;
18302 segs.forEach(function(s){
18303 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18304 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18305 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18306 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18307 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18308 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)+'%')+'/>';
18309 ang+=sw;
18310 });
18311 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>';
18312 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18313 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>';});
18314 c4+='</svg>';
18315
18316 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18317 var ttJs='var tt=document.getElementById("ox-tt");'+
18318 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18319 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18320 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18321 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18322 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18323 'function oxHT(){tt.style.display="none";}';
18324
18325 // body max-width keeps charts from inflating beyond design dimensions on
18326 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18327 // each chart's height blows up proportionally, breaking the one-page layout.
18328 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;}'+
18329 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18330 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18331 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18332 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18333 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18334 'svg{display:block;}'+
18335 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18336 '#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;}'+
18337 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18338 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18339 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18340 '<div id="ox-tt"><\/div>'+
18341 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
18342 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
18343 '<div class="two-col">'+
18344 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
18345 '<div class="leg">'+
18346 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18347 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18348 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18349 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
18350 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18351 '<\/div>'+
18352 '<div class="two-col">'+
18353 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18354 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18355 '<\/div>'+
18356 '<script>'+ttJs+'<\/script>'+
18357 '<\/body><\/html>';
18358 slocDownload(html, fname, 'text/html;charset=utf-8;');
18359 }
18360 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18361 // ── Inline delta charts ────────────────────────────────────────────────────
18362 var _icTT=document.getElementById('ic-tt');
18363 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18364 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';};
18365 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18366 (function(){
18367 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18368 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
18369 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();}
18370 function px(n){return Math.round(n);}
18371 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18372 function btt(l,v){return ' class="ic-cb" onmouseover="icTT(event,\''+jsq(l)+'\',\''+jsq(v)+'\')" onmouseout="icHT()" onmousemove="icMT(event)"';}
18373 var dr=getDeltaExportRows(),sd=_sd,lm={};
18374 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;});
18375 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18376 // Chart 1: Baseline vs Current grouped bars
18377 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'}];
18378 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18379 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;
18380 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18381 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"/>';}
18382 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18383 c1mets.forEach(function(m,i){
18384 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18385 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18386 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>';
18387 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"/>';
18388 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>';
18389 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"/>';
18390 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>';
18391 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>';
18392 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>';
18393 });
18394 c1+='</svg>';
18395 // Chart 2: Delta by Metric
18396 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'}];
18397 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18398 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;
18399 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18400 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18401 mets.forEach(function(m,i){
18402 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);
18403 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>';
18404 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18405 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>';}
18406 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>';}
18407 });
18408 c2+='</svg>';
18409 // Chart 3: Language Code Delta
18410 var c3='';
18411 if(langs.length){
18412 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18413 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;
18414 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18415 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18416 langs.forEach(function(l,i){
18417 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);
18418 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18419 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"/>';
18420 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>';}
18421 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>';}
18422 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>';
18423 });
18424 c3+='</svg>';
18425 }
18426 // Chart 4: File Change Donut
18427 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;});
18428 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18429 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;
18430 if(segs.length===1){
18431 // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18432 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18433 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18434 } else {
18435 segs.forEach(function(s){
18436 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18437 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);
18438 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);
18439 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"/>';
18440 ang+=sw;
18441 });
18442 }
18443 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>';
18444 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18445 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>';});
18446 c4+='</svg>';
18447 var e1=document.getElementById('ic-c1');if(e1)e1.innerHTML=c1;
18448 var e2=document.getElementById('ic-c2');if(e2)e2.innerHTML=c2;
18449 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>';
18450 var e4=document.getElementById('ic-c4');if(e4)e4.innerHTML=c4;
18451 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18452 })();
18453 </script>
18454 <script nonce="{{ csp_nonce }}">
18455 (function(){
18456 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'}];
18457 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);});}
18458 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18459 function init(){
18460 var btn=document.getElementById('settings-btn');if(!btn)return;
18461 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18462 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>';
18463 document.body.appendChild(m);
18464 var g=document.getElementById('scheme-grid');
18465 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);});
18466 var cl=document.getElementById('settings-close');
18467 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);
18468 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');});
18469 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18470 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18471 }
18472 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18473 }());
18474 </script>
18475</body>
18476</html>
18477"##,
18478 ext = "html"
18479)]
18480#[allow(clippy::struct_excessive_bools)]
18482struct CompareTemplate {
18483 version: &'static str,
18484 project_label: String,
18485 baseline_git_commit: String,
18486 current_git_commit: String,
18487 baseline_run_id: String,
18488 current_run_id: String,
18489 baseline_run_id_short: String,
18490 current_run_id_short: String,
18491 baseline_timestamp: String,
18492 baseline_timestamp_utc_ms: i64,
18493 current_timestamp: String,
18494 current_timestamp_utc_ms: i64,
18495 project_path: String,
18496 baseline_code: u64,
18497 current_code: u64,
18498 code_lines_delta_str: String,
18499 code_lines_delta_class: String,
18500 baseline_files: u64,
18501 current_files: u64,
18502 files_analyzed_delta_str: String,
18503 files_analyzed_delta_class: String,
18504 baseline_comments: u64,
18505 current_comments: u64,
18506 comment_lines_delta_str: String,
18507 comment_lines_delta_class: String,
18508 code_lines_pct_str: String,
18509 files_analyzed_pct_str: String,
18510 comment_lines_pct_str: String,
18511 code_lines_added: i64,
18512 code_lines_removed: i64,
18513 new_scope: bool,
18515 churn_rate_str: String,
18516 churn_rate_class: String,
18517 scope_flag: bool,
18518 files_added: usize,
18519 files_removed: usize,
18520 files_modified: usize,
18521 files_unchanged: usize,
18522 file_rows: Vec<CompareFileDeltaRow>,
18523 baseline_git_author: Option<String>,
18524 current_git_author: Option<String>,
18525 baseline_git_branch: String,
18526 current_git_branch: String,
18527 baseline_git_tags: Option<String>,
18528 current_git_tags: Option<String>,
18529 baseline_git_commit_date: Option<String>,
18530 current_git_commit_date: Option<String>,
18531 project_name: String,
18532 submodule_options: Vec<String>,
18534 has_any_submodule_data: bool,
18536 active_submodule: Option<String>,
18538 super_scope_active: bool,
18540 csp_nonce: String,
18541}
18542
18543#[derive(Template)]
18546#[template(
18547 source = r##"
18548<!doctype html>
18549<html lang="en">
18550<head>
18551 <meta charset="utf-8">
18552 <meta name="viewport" content="width=device-width, initial-scale=1">
18553 <title>OxideSLOC | Sign In</title>
18554 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18555 <style nonce="{{ csp_nonce }}">
18556 :root {
18557 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18558 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18559 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18560 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18561 }
18562 *{box-sizing:border-box;}
18563 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);}
18564 .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);}
18565 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18566 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18567 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18568 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18569 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18570 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18571 .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;}
18572 @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));}}
18573 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18574 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18575 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18576 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18577 .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;}
18578 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18579 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;}
18580 input[type=password]:focus{border-color:var(--oxide);}
18581 .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;}
18582 .btn:hover{opacity:.88;}
18583 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18584 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18585 </style>
18586</head>
18587<body>
18588 <div class="background-watermarks" aria-hidden="true">
18589 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18590 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18591 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18592 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18593 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18594 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18595 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18596 </div>
18597 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18598<nav class="top-nav">
18599 <a class="brand" href="/">
18600 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18601 <span class="brand-title">OxideSLOC</span>
18602 </a>
18603</nav>
18604<main class="page">
18605 <div class="card">
18606 <h1>Sign In</h1>
18607 <p class="subtitle">Enter the API key printed when the server started.</p>
18608 {% if has_error %}
18609 <div class="error">Incorrect API key — please try again.</div>
18610 {% endif %}
18611 <form method="POST" action="/auth/login">
18612 <input type="hidden" name="next" value="{{ next_url|e }}">
18613 <label for="key">API Key</label>
18614 <input id="key" type="password" name="key" autocomplete="current-password"
18615 placeholder="Paste your API key here" autofocus>
18616 <button type="submit" class="btn">Sign In</button>
18617 </form>
18618 <p class="hint">
18619 The API key was printed in the terminal when the server started.<br>
18620 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18621 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18622 </p>
18623 </div>
18624</main>
18625<script nonce="{{ csp_nonce }}">
18626(function() {
18627 (function randomizeWatermarks() {
18628 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18629 if (!wms.length) return;
18630 var placed = [];
18631 function tooClose(top, left) {
18632 for (var i = 0; i < placed.length; i++) {
18633 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18634 if (dt < 16 && dl < 12) return true;
18635 }
18636 return false;
18637 }
18638 function pick(leftBand) {
18639 for (var attempt = 0; attempt < 50; attempt++) {
18640 var top = Math.random() * 88 + 2;
18641 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18642 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18643 }
18644 var top = Math.random() * 88 + 2;
18645 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18646 placed.push([top, left]); return [top, left];
18647 }
18648 var half = Math.floor(wms.length / 2);
18649 wms.forEach(function (img, i) {
18650 var pos = pick(i < half);
18651 var size = Math.floor(Math.random() * 100 + 120);
18652 var rot = (Math.random() * 360).toFixed(1);
18653 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18654 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;
18655 });
18656 })();
18657 (function spawnCodeParticles() {
18658 var container = document.getElementById('code-particles');
18659 if (!container) return;
18660 var snippets = [
18661 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18662 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18663 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18664 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18665 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18666 ];
18667 var count = 38;
18668 for (var i = 0; i < count; i++) {
18669 (function(idx) {
18670 var el = document.createElement('span');
18671 el.className = 'code-particle';
18672 el.textContent = snippets[idx % snippets.length];
18673 var left = Math.random() * 94 + 2;
18674 var top = Math.random() * 88 + 6;
18675 var dur = (Math.random() * 10 + 9).toFixed(1);
18676 var delay = (Math.random() * 18).toFixed(1);
18677 var rot = (Math.random() * 26 - 13).toFixed(1);
18678 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18679 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18680 container.appendChild(el);
18681 })(i);
18682 }
18683 })();
18684})();
18685</script>
18686</body>
18687</html>
18688"##,
18689 ext = "html"
18690)]
18691struct LoginTemplate {
18692 csp_nonce: String,
18693 has_error: bool,
18694 next_url: String,
18695 lockout_threshold: u32,
18696}
18697
18698#[derive(Template)]
18701#[template(
18702 source = r##"
18703<!doctype html>
18704<html lang="en">
18705<head>
18706 <meta charset="utf-8">
18707 <meta name="viewport" content="width=device-width, initial-scale=1">
18708 <title>OxideSLOC — REST API Reference</title>
18709 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18710 <style nonce="{{ csp_nonce }}">
18711 :root {
18712 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18713 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18714 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18715 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18716 --success:#16a34a;
18717 }
18718 body.dark-theme {
18719 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18720 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18721 }
18722 *{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);}
18723 .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);}
18724 .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;}
18725 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18726 .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));}
18727 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18728 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18729 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18730 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18731 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18732 @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; } }
18733 .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;}
18734 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18735 .nav-pill.active{background:rgba(255,255,255,0.22);}
18736 .nav-dropdown{position:relative;display:inline-flex;}
18737 .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;}
18738 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18739 .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;}
18740 .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;}
18741 .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);}
18742 .nav-dropdown-menu a:last-child{border-bottom:none;}
18743 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18744 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18745 .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;}
18746 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18747 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18748 .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;}
18749 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18750 .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);}
18751 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18752 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18753 .settings-modal-body{padding:14px 16px 16px;}
18754 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18755 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18756 .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;}
18757 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18758 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18759 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18760 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18761 .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;}
18762 .tz-select:focus{border-color:var(--oxide);}
18763 .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18764 .page-header{margin-bottom:28px;}
18765 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18766 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18767 .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;}
18768 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18769 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18770 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18771 .callout strong{font-weight:800;}
18772 .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;}
18773 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18774 .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;}
18775 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18776 .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;}
18777 body.dark-theme .base-url-value{color:var(--accent);}
18778 .section{margin-bottom:36px;}
18779 .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);}
18780 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18781 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18782 .ep-header:hover{background:var(--surface-2);}
18783 .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;}
18784 .method.get{background:#dcfce7;color:#166534;}
18785 .method.post{background:#dbeafe;color:#1e40af;}
18786 .method.delete{background:#fee2e2;color:#991b1b;}
18787 body.dark-theme .method.get{background:#14532d;color:#86efac;}
18788 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18789 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18790 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18791 .ep-path .param{color:var(--oxide-2);}
18792 body.dark-theme .ep-path .param{color:var(--oxide);}
18793 .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;}
18794 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18795 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18796 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18797 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18798 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18799 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18800 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18801 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18802 .ep-card.open .chevron{transform:rotate(180deg);}
18803 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18804 .ep-card.open .ep-body{display:block;}
18805 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18806 .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;}
18807 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18808 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18809 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18810 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18811 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);}
18812 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18813 table.params tr:last-child td{border-bottom:none;}
18814 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18815 .pt-type{color:var(--muted-2);font-size:12px;}
18816 .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;}
18817 .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;}
18818 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18819 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18820 details.schema{margin-bottom:14px;}
18821 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;}
18822 details.schema summary:hover{color:var(--text);}
18823 .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;}
18824 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18825 .curl-wrap{position:relative;}
18826 .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;}
18827 .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;}
18828 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18829 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18830 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18831 .webhook-note a{color:var(--accent-2);text-decoration:none;}
18832 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18833 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18834 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18835 .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;}
18836 @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));}}
18837 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18838 .site-footer a{color:var(--muted);}
18839 </style>
18840</head>
18841<body>
18842 <div class="background-watermarks" aria-hidden="true">
18843 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18844 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18845 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18846 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18847 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18848 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18849 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18850 </div>
18851 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18852 <div class="top-nav">
18853 <div class="top-nav-inner">
18854 <a class="brand" href="/">
18855 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18856 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18857 </a>
18858 <div class="nav-right">
18859 <a class="nav-pill" href="/">Home</a>
18860 <div class="nav-dropdown">
18861 <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>
18862 <div class="nav-dropdown-menu">
18863 <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>
18864 </div>
18865 </div>
18866 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18867 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18868 <div class="nav-dropdown">
18869 <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>
18870 <div class="nav-dropdown-menu">
18871 <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>
18872 </div>
18873 </div>
18874 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18875 <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>
18876 </button>
18877 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18878 <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>
18879 <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>
18880 </button>
18881 </div>
18882 </div>
18883 </div>
18884
18885 <div class="page">
18886 <div class="page-header">
18887 <h1 class="page-title">REST API Reference</h1>
18888 <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>
18889 </div>
18890
18891 {% if has_api_key %}
18892 <div class="callout key-set">
18893 <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>
18894 <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>
18895 </div>
18896 {% else %}
18897 <div class="callout no-key">
18898 <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>
18899 <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>
18900 </div>
18901 {% endif %}
18902
18903 <div class="base-url-bar">
18904 <span class="base-url-label">Base URL</span>
18905 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18906 </div>
18907
18908 <!-- Health -->
18909 <div class="section">
18910 <h2 class="section-title">Health & Status</h2>
18911 <div class="ep-card">
18912 <div class="ep-header">
18913 <span class="method get">GET</span>
18914 <span class="ep-path">/healthz</span>
18915 <span class="auth-badge public">Public</span>
18916 <span class="ep-desc">Server liveness check</span>
18917 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18918 </div>
18919 <div class="ep-body">
18920 <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>
18921 <p class="params-heading">Response</p>
18922 <div class="schema-block">200 OK
18923Content-Type: text/plain
18924
18925ok</div>
18926 <p class="curl-heading">Example</p>
18927 <div class="curl-wrap">
18928 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18929 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18930 </div>
18931 </div>
18932 </div>
18933 </div>
18934
18935 <!-- Badges -->
18936 <div class="section">
18937 <h2 class="section-title">Badges</h2>
18938 <div class="ep-card">
18939 <div class="ep-header">
18940 <span class="method get">GET</span>
18941 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18942 <span class="auth-badge public">Public</span>
18943 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18944 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18945 </div>
18946 <div class="ep-body">
18947 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18948 <p class="params-heading">Path Parameters</p>
18949 <table class="params">
18950 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18951 <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>
18952 </table>
18953 <p class="curl-heading">Example</p>
18954 <div class="curl-wrap">
18955 <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>
18956 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18957 </div>
18958 </div>
18959 </div>
18960 </div>
18961
18962 <!-- Metrics -->
18963 <div class="section">
18964 <h2 class="section-title">Metrics</h2>
18965
18966 <div class="ep-card">
18967 <div class="ep-header">
18968 <span class="method get">GET</span>
18969 <span class="ep-path">/api/metrics/latest</span>
18970 <span class="auth-badge protected">Protected</span>
18971 <span class="ep-desc">Latest scan metrics (JSON)</span>
18972 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18973 </div>
18974 <div class="ep-body">
18975 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18976 <details class="schema"><summary>Response schema</summary>
18977<div class="schema-block">{
18978 "run_id": string, // UUID
18979 "timestamp": string, // ISO-8601 UTC
18980 "project": string, // scanned root path
18981 "summary": {
18982 "files_analyzed": number,
18983 "files_skipped": number,
18984 "code_lines": number,
18985 "comment_lines": number,
18986 "blank_lines": number,
18987 "total_physical_lines": number,
18988 "functions": number,
18989 "classes": number,
18990 "variables": number,
18991 "imports": number
18992 },
18993 "languages": [
18994 { "name": string, "files": number, "code_lines": number,
18995 "comment_lines": number, "blank_lines": number,
18996 "functions": number, "classes": number,
18997 "variables": number, "imports": number }
18998 ]
18999}</div></details>
19000 <p class="curl-heading">Example</p>
19001 <div class="curl-wrap">
19002 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19003 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
19004 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
19005 </div>
19006 </div>
19007 </div>
19008
19009 <div class="ep-card">
19010 <div class="ep-header">
19011 <span class="method get">GET</span>
19012 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
19013 <span class="auth-badge protected">Protected</span>
19014 <span class="ep-desc">Metrics for a specific run</span>
19015 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19016 </div>
19017 <div class="ep-body">
19018 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
19019 <p class="params-heading">Path Parameters</p>
19020 <table class="params">
19021 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19022 <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>
19023 </table>
19024 <p class="curl-heading">Example</p>
19025 <div class="curl-wrap">
19026 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19027 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
19028 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
19029 </div>
19030 </div>
19031 </div>
19032
19033 <div class="ep-card">
19034 <div class="ep-header">
19035 <span class="method get">GET</span>
19036 <span class="ep-path">/api/metrics/history</span>
19037 <span class="auth-badge protected">Protected</span>
19038 <span class="ep-desc">Paginated scan history</span>
19039 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19040 </div>
19041 <div class="ep-body">
19042 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
19043 <p class="params-heading">Query Parameters</p>
19044 <table class="params">
19045 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19046 <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>
19047 <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>
19048 </table>
19049 <details class="schema"><summary>Response schema</summary>
19050<div class="schema-block">[{
19051 "run_id": string,
19052 "timestamp": string, // ISO-8601 UTC
19053 "commit": string | null,
19054 "branch": string | null,
19055 "tags": string[],
19056 "code_lines": number,
19057 "comment_lines": number,
19058 "blank_lines": number,
19059 "physical_lines": number,
19060 "files_analyzed": number,
19061 "project_label": string,
19062 "html_url": string | null
19063}]</div></details>
19064 <p class="curl-heading">Example</p>
19065 <div class="curl-wrap">
19066 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19067 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
19068 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
19069 </div>
19070 </div>
19071 </div>
19072
19073 <div class="ep-card">
19074 <div class="ep-header">
19075 <span class="method get">GET</span>
19076 <span class="ep-path">/api/project-history</span>
19077 <span class="auth-badge protected">Protected</span>
19078 <span class="ep-desc">Project-level scan summary</span>
19079 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19080 </div>
19081 <div class="ep-body">
19082 <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>
19083 <p class="params-heading">Query Parameters</p>
19084 <table class="params">
19085 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19086 <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>
19087 </table>
19088 <details class="schema"><summary>Response schema</summary>
19089<div class="schema-block">{
19090 "scan_count": number,
19091 "last_scan_id": string | null,
19092 "last_scan_timestamp": string | null, // ISO-8601
19093 "last_scan_code_lines": number | null,
19094 "last_git_branch": string | null,
19095 "last_git_commit": string | null
19096}</div></details>
19097 <p class="curl-heading">Example</p>
19098 <div class="curl-wrap">
19099 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19100 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
19101 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
19102 </div>
19103 </div>
19104 </div>
19105
19106 <div class="ep-card">
19107 <div class="ep-header">
19108 <span class="method get">GET</span>
19109 <span class="ep-path">/api/metrics/submodules</span>
19110 <span class="auth-badge protected">Protected</span>
19111 <span class="ep-desc">List known git submodules across scans</span>
19112 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19113 </div>
19114 <div class="ep-body">
19115 <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>
19116 <p class="params-heading">Query Parameters</p>
19117 <table class="params">
19118 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19119 <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>
19120 </table>
19121 <details class="schema"><summary>Response schema</summary>
19122<div class="schema-block">[{
19123 "name": string, // submodule name
19124 "relative_path": string // path relative to the project root
19125}]</div></details>
19126 <p class="curl-heading">Example</p>
19127 <div class="curl-wrap">
19128 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19129 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
19130 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
19131 </div>
19132 </div>
19133 </div>
19134 </div>
19135
19136 <!-- Async Run Status -->
19137 <div class="section">
19138 <h2 class="section-title">Async Run Status</h2>
19139
19140 <div class="ep-card">
19141 <div class="ep-header">
19142 <span class="method get">GET</span>
19143 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
19144 <span class="auth-badge protected">Protected</span>
19145 <span class="ep-desc">Poll scan completion</span>
19146 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19147 </div>
19148 <div class="ep-body">
19149 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
19150 <details class="schema"><summary>Response schema</summary>
19151<div class="schema-block">// Running
19152{ "state": "running", "elapsed_secs": number }
19153
19154// Complete
19155{ "state": "complete", "run_id": string }
19156
19157// Failed
19158{ "state": "failed", "message": string }</div></details>
19159 <p class="curl-heading">Example</p>
19160 <div class="curl-wrap">
19161 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19162 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
19163 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
19164 </div>
19165 </div>
19166 </div>
19167
19168 <div class="ep-card">
19169 <div class="ep-header">
19170 <span class="method get">GET</span>
19171 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
19172 <span class="auth-badge protected">Protected</span>
19173 <span class="ep-desc">Poll PDF generation readiness</span>
19174 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19175 </div>
19176 <div class="ep-body">
19177 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
19178 <details class="schema"><summary>Response schema</summary>
19179<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
19180 <p class="curl-heading">Example</p>
19181 <div class="curl-wrap">
19182 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19183 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
19184 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
19185 </div>
19186 </div>
19187 </div>
19188
19189 <div class="ep-card">
19190 <div class="ep-header">
19191 <span class="method post">POST</span>
19192 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
19193 <span class="auth-badge protected">Protected</span>
19194 <span class="ep-desc">Cancel a running scan</span>
19195 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19196 </div>
19197 <div class="ep-body">
19198 <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>
19199 <p class="curl-heading">Example</p>
19200 <div class="curl-wrap">
19201 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
19202 -H "Authorization: Bearer $SLOC_API_KEY" \
19203 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
19204 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
19205 </div>
19206 </div>
19207 </div>
19208 </div>
19209
19210 <!-- Scan Profiles -->
19211 <div class="section">
19212 <h2 class="section-title">Scan Profiles</h2>
19213
19214 <div class="ep-card">
19215 <div class="ep-header">
19216 <span class="method get">GET</span>
19217 <span class="ep-path">/api/scan-profiles</span>
19218 <span class="auth-badge protected">Protected</span>
19219 <span class="ep-desc">List saved scan profiles</span>
19220 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19221 </div>
19222 <div class="ep-body">
19223 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19224 <details class="schema"><summary>Response schema</summary>
19225<div class="schema-block">{
19226 "profiles": [{
19227 "id": string, // UUID
19228 "name": string,
19229 "created_at": string, // ISO-8601
19230 "params": object
19231 }]
19232}</div></details>
19233 <p class="curl-heading">Example</p>
19234 <div class="curl-wrap">
19235 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19236 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19237 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19238 </div>
19239 </div>
19240 </div>
19241
19242 <div class="ep-card">
19243 <div class="ep-header">
19244 <span class="method post">POST</span>
19245 <span class="ep-path">/api/scan-profiles</span>
19246 <span class="auth-badge protected">Protected</span>
19247 <span class="ep-desc">Save a scan profile</span>
19248 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19249 </div>
19250 <div class="ep-body">
19251 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19252 <p class="params-heading">Request Body (application/json)</p>
19253 <table class="params">
19254 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19255 <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>
19256 <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>
19257 </table>
19258 <details class="schema"><summary>Response schema</summary>
19259<div class="schema-block">{ "ok": true }</div></details>
19260 <p class="curl-heading">Example</p>
19261 <div class="curl-wrap">
19262 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19263 -H "Authorization: Bearer $SLOC_API_KEY" \
19264 -H "Content-Type: application/json" \
19265 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19266 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19267 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19268 </div>
19269 </div>
19270 </div>
19271
19272 <div class="ep-card">
19273 <div class="ep-header">
19274 <span class="method delete">DELETE</span>
19275 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19276 <span class="auth-badge protected">Protected</span>
19277 <span class="ep-desc">Delete a scan profile</span>
19278 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19279 </div>
19280 <div class="ep-body">
19281 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19282 <p class="params-heading">Path Parameters</p>
19283 <table class="params">
19284 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19285 <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>
19286 </table>
19287 <details class="schema"><summary>Response schema</summary>
19288<div class="schema-block">{ "ok": true }</div></details>
19289 <p class="curl-heading">Example</p>
19290 <div class="curl-wrap">
19291 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19292 -H "Authorization: Bearer $SLOC_API_KEY" \
19293 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
19294 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19295 </div>
19296 </div>
19297 </div>
19298 </div>
19299
19300 <!-- Scheduled Scans -->
19301 <div class="section">
19302 <h2 class="section-title">Scheduled Scans</h2>
19303
19304 <div class="ep-card">
19305 <div class="ep-header">
19306 <span class="method get">GET</span>
19307 <span class="ep-path">/api/schedules</span>
19308 <span class="auth-badge protected">Protected</span>
19309 <span class="ep-desc">List configured schedules</span>
19310 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19311 </div>
19312 <div class="ep-body">
19313 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19314 <p class="curl-heading">Example</p>
19315 <div class="curl-wrap">
19316 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19317 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19318 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
19319 </div>
19320 </div>
19321 </div>
19322
19323 <div class="ep-card">
19324 <div class="ep-header">
19325 <span class="method post">POST</span>
19326 <span class="ep-path">/api/schedules</span>
19327 <span class="auth-badge protected">Protected</span>
19328 <span class="ep-desc">Create a schedule</span>
19329 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19330 </div>
19331 <div class="ep-body">
19332 <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>
19333 <p class="curl-heading">Example</p>
19334 <div class="curl-wrap">
19335 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19336 -H "Authorization: Bearer $SLOC_API_KEY" \
19337 -H "Content-Type: application/json" \
19338 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
19339 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19340 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
19341 </div>
19342 </div>
19343 </div>
19344
19345 <div class="ep-card">
19346 <div class="ep-header">
19347 <span class="method delete">DELETE</span>
19348 <span class="ep-path">/api/schedules</span>
19349 <span class="auth-badge protected">Protected</span>
19350 <span class="ep-desc">Delete a schedule</span>
19351 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19352 </div>
19353 <div class="ep-body">
19354 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19355 <p class="curl-heading">Example</p>
19356 <div class="curl-wrap">
19357 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19358 -H "Authorization: Bearer $SLOC_API_KEY" \
19359 -H "Content-Type: application/json" \
19360 -d '{"id":"<schedule_id>"}' \
19361 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19362 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19363 </div>
19364 </div>
19365 </div>
19366 </div>
19367
19368 <!-- Git Browser -->
19369 <div class="section">
19370 <h2 class="section-title">Git Browser</h2>
19371
19372 <div class="ep-card">
19373 <div class="ep-header">
19374 <span class="method get">GET</span>
19375 <span class="ep-path">/api/git/refs</span>
19376 <span class="auth-badge protected">Protected</span>
19377 <span class="ep-desc">List git refs for a repository</span>
19378 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19379 </div>
19380 <div class="ep-body">
19381 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19382 <p class="params-heading">Query Parameters</p>
19383 <table class="params">
19384 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19385 <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>
19386 </table>
19387 <p class="curl-heading">Example</p>
19388 <div class="curl-wrap">
19389 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19390 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19391 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19392 </div>
19393 </div>
19394 </div>
19395
19396 <div class="ep-card">
19397 <div class="ep-header">
19398 <span class="method get">GET</span>
19399 <span class="ep-path">/api/git/scan-ref</span>
19400 <span class="auth-badge protected">Protected</span>
19401 <span class="ep-desc">SLOC-scan a specific git ref</span>
19402 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19403 </div>
19404 <div class="ep-body">
19405 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19406 <p class="params-heading">Query Parameters</p>
19407 <table class="params">
19408 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19409 <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>
19410 <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>
19411 </table>
19412 <p class="curl-heading">Example</p>
19413 <div class="curl-wrap">
19414 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19415 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
19416 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19417 </div>
19418 </div>
19419 </div>
19420
19421 <div class="ep-card">
19422 <div class="ep-header">
19423 <span class="method get">GET</span>
19424 <span class="ep-path">/api/git/compare-refs</span>
19425 <span class="auth-badge protected">Protected</span>
19426 <span class="ep-desc">Compare SLOC across two git refs</span>
19427 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19428 </div>
19429 <div class="ep-body">
19430 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19431 <p class="params-heading">Query Parameters</p>
19432 <table class="params">
19433 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19434 <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>
19435 <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>
19436 <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>
19437 </table>
19438 <p class="curl-heading">Example</p>
19439 <div class="curl-wrap">
19440 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19441 "<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>
19442 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19443 </div>
19444 </div>
19445 </div>
19446 </div>
19447
19448 <!-- Webhooks -->
19449 <div class="section">
19450 <h2 class="section-title">Webhooks</h2>
19451 <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>
19452
19453 <div class="ep-card">
19454 <div class="ep-header">
19455 <span class="method post">POST</span>
19456 <span class="ep-path">/webhooks/github</span>
19457 <span class="auth-badge hmac">HMAC</span>
19458 <span class="ep-desc">GitHub push event receiver</span>
19459 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19460 </div>
19461 <div class="ep-body">
19462 <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>
19463 <p class="params-heading">Required Headers</p>
19464 <table class="params">
19465 <tr><th>Header</th><th>Value</th></tr>
19466 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19467 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19468 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19469 </table>
19470 </div>
19471 </div>
19472
19473 <div class="ep-card">
19474 <div class="ep-header">
19475 <span class="method post">POST</span>
19476 <span class="ep-path">/webhooks/gitlab</span>
19477 <span class="auth-badge hmac">HMAC</span>
19478 <span class="ep-desc">GitLab push event receiver</span>
19479 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19480 </div>
19481 <div class="ep-body">
19482 <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>
19483 <p class="params-heading">Required Headers</p>
19484 <table class="params">
19485 <tr><th>Header</th><th>Value</th></tr>
19486 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19487 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19488 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19489 </table>
19490 </div>
19491 </div>
19492
19493 <div class="ep-card">
19494 <div class="ep-header">
19495 <span class="method post">POST</span>
19496 <span class="ep-path">/webhooks/bitbucket</span>
19497 <span class="auth-badge hmac">HMAC</span>
19498 <span class="ep-desc">Bitbucket push event receiver</span>
19499 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19500 </div>
19501 <div class="ep-body">
19502 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19503 <p class="params-heading">Required Headers</p>
19504 <table class="params">
19505 <tr><th>Header</th><th>Value</th></tr>
19506 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19507 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19508 </table>
19509 </div>
19510 </div>
19511 </div>
19512
19513 <!-- Config -->
19514 <div class="section">
19515 <h2 class="section-title">Config Import / Export</h2>
19516
19517 <div class="ep-card">
19518 <div class="ep-header">
19519 <span class="method get">GET</span>
19520 <span class="ep-path">/export-config</span>
19521 <span class="auth-badge protected">Protected</span>
19522 <span class="ep-desc">Export server configuration as JSON</span>
19523 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19524 </div>
19525 <div class="ep-body">
19526 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19527 <p class="curl-heading">Example</p>
19528 <div class="curl-wrap">
19529 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19530 -o config.json \
19531 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19532 <button class="curl-copy-btn" data-target="c-export">Copy</button>
19533 </div>
19534 </div>
19535 </div>
19536
19537 <div class="ep-card">
19538 <div class="ep-header">
19539 <span class="method post">POST</span>
19540 <span class="ep-path">/import-config</span>
19541 <span class="auth-badge protected">Protected</span>
19542 <span class="ep-desc">Import server configuration</span>
19543 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19544 </div>
19545 <div class="ep-body">
19546 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19547 <p class="curl-heading">Example</p>
19548 <div class="curl-wrap">
19549 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19550 -H "Authorization: Bearer $SLOC_API_KEY" \
19551 -H "Content-Type: application/json" \
19552 -d @config.json \
19553 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19554 <button class="curl-copy-btn" data-target="c-import">Copy</button>
19555 </div>
19556 </div>
19557 </div>
19558 </div>
19559
19560 <!-- CI Ingest -->
19561 <div class="section">
19562 <h2 class="section-title">CI Ingest</h2>
19563
19564 <div class="ep-card">
19565 <div class="ep-header">
19566 <span class="method post">POST</span>
19567 <span class="ep-path">/api/ingest</span>
19568 <span class="auth-badge protected">Protected</span>
19569 <span class="ep-desc">Push a pre-computed scan result from CI</span>
19570 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19571 </div>
19572 <div class="ep-body">
19573 <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>
19574 <p class="params-heading">Query Parameters</p>
19575 <table class="params">
19576 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19577 <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>
19578 </table>
19579 <p class="params-heading">Request Body (application/json)</p>
19580 <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>
19581 <details class="schema"><summary>Response schema</summary>
19582<div class="schema-block">// 201 Created
19583{
19584 "run_id": string, // UUID of the ingested run
19585 "view_url": string // relative URL to the report page
19586}</div></details>
19587 <p class="curl-heading">Example</p>
19588 <div class="curl-wrap">
19589 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19590 -H "Authorization: Bearer $SLOC_API_KEY" \
19591 -H "Content-Type: application/json" \
19592 -d @result.json \
19593 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19594 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19595 </div>
19596 </div>
19597 </div>
19598 </div>
19599
19600 <!-- Artifact Download -->
19601 <div class="section">
19602 <h2 class="section-title">Artifact Download</h2>
19603
19604 <div class="ep-card">
19605 <div class="ep-header">
19606 <span class="method get">GET</span>
19607 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19608 <span class="auth-badge protected">Protected</span>
19609 <span class="ep-desc">Download or view a scan artifact</span>
19610 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19611 </div>
19612 <div class="ep-body">
19613 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19614 <p class="params-heading">Path Parameters</p>
19615 <table class="params">
19616 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19617 <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>
19618 <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>
19619 </table>
19620 <p class="params-heading">Query Parameters</p>
19621 <table class="params">
19622 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19623 <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>
19624 </table>
19625 <p class="curl-heading">Example — download JSON result</p>
19626 <div class="curl-wrap">
19627 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19628 -o result.json \
19629 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
19630 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19631 </div>
19632 </div>
19633 </div>
19634 </div>
19635
19636 <!-- Embed Widget -->
19637 <div class="section">
19638 <h2 class="section-title">Embed Widget</h2>
19639
19640 <div class="ep-card">
19641 <div class="ep-header">
19642 <span class="method get">GET</span>
19643 <span class="ep-path">/embed/summary</span>
19644 <span class="auth-badge protected">Protected</span>
19645 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19646 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19647 </div>
19648 <div class="ep-body">
19649 <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>
19650 <p class="params-heading">Query Parameters</p>
19651 <table class="params">
19652 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19653 <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>
19654 <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>
19655 </table>
19656 <p class="curl-heading">Example</p>
19657 <div class="curl-wrap">
19658 <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"
19659 width="460" height="260" style="border:none"></iframe></pre>
19660 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19661 </div>
19662 </div>
19663 </div>
19664 </div>
19665
19666 <!-- Confluence Integration -->
19667 <div class="section">
19668 <h2 class="section-title">Confluence Integration</h2>
19669
19670 <div class="ep-card">
19671 <div class="ep-header">
19672 <span class="method get">GET</span>
19673 <span class="ep-path">/api/confluence/config</span>
19674 <span class="auth-badge protected">Protected</span>
19675 <span class="ep-desc">Get current Confluence configuration</span>
19676 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19677 </div>
19678 <div class="ep-body">
19679 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19680 <details class="schema"><summary>Response schema</summary>
19681<div class="schema-block">{
19682 "configured": boolean,
19683 "tier": "cloud" | "server",
19684 "base_url": string,
19685 "username": string,
19686 "api_token_set": boolean,
19687 "space_key": string,
19688 "parent_page_id": string | null,
19689 "schedule_auto_post": { "<schedule_id>": boolean }
19690}</div></details>
19691 <p class="curl-heading">Example</p>
19692 <div class="curl-wrap">
19693 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19694 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19695 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19696 </div>
19697 </div>
19698 </div>
19699
19700 <div class="ep-card">
19701 <div class="ep-header">
19702 <span class="method post">POST</span>
19703 <span class="ep-path">/api/confluence/config</span>
19704 <span class="auth-badge protected">Protected</span>
19705 <span class="ep-desc">Save Confluence configuration</span>
19706 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19707 </div>
19708 <div class="ep-body">
19709 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19710 <p class="params-heading">Request Body (application/json)</p>
19711 <table class="params">
19712 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19713 <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>
19714 <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>
19715 <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>
19716 <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>
19717 <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>
19718 <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>
19719 <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>
19720 </table>
19721 <details class="schema"><summary>Response schema</summary>
19722<div class="schema-block">{ "ok": true }</div></details>
19723 <p class="curl-heading">Example</p>
19724 <div class="curl-wrap">
19725 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19726 -H "Authorization: Bearer $SLOC_API_KEY" \
19727 -H "Content-Type: application/json" \
19728 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19729 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19730 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
19731 </div>
19732 </div>
19733 </div>
19734
19735 <div class="ep-card">
19736 <div class="ep-header">
19737 <span class="method post">POST</span>
19738 <span class="ep-path">/api/confluence/test</span>
19739 <span class="auth-badge protected">Protected</span>
19740 <span class="ep-desc">Test Confluence connection</span>
19741 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19742 </div>
19743 <div class="ep-body">
19744 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19745 <details class="schema"><summary>Response schema</summary>
19746<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19747 <p class="curl-heading">Example</p>
19748 <div class="curl-wrap">
19749 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19750 -H "Authorization: Bearer $SLOC_API_KEY" \
19751 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19752 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19753 </div>
19754 </div>
19755 </div>
19756
19757 <div class="ep-card">
19758 <div class="ep-header">
19759 <span class="method post">POST</span>
19760 <span class="ep-path">/api/confluence/post</span>
19761 <span class="auth-badge protected">Protected</span>
19762 <span class="ep-desc">Publish a scan report to Confluence</span>
19763 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19764 </div>
19765 <div class="ep-body">
19766 <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>
19767 <p class="params-heading">Request Body (application/json)</p>
19768 <table class="params">
19769 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19770 <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>
19771 <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>
19772 <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>
19773 </table>
19774 <details class="schema"><summary>Response schema</summary>
19775<div class="schema-block">// 200 OK
19776{ "ok": true, "page_id": string }
19777
19778// 400 / 502 on error
19779{ "ok": false, "error": string }</div></details>
19780 <p class="curl-heading">Example</p>
19781 <div class="curl-wrap">
19782 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19783 -H "Authorization: Bearer $SLOC_API_KEY" \
19784 -H "Content-Type: application/json" \
19785 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
19786 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19787 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19788 </div>
19789 </div>
19790 </div>
19791
19792 <div class="ep-card">
19793 <div class="ep-header">
19794 <span class="method get">GET</span>
19795 <span class="ep-path">/api/confluence/wiki-markup</span>
19796 <span class="auth-badge protected">Protected</span>
19797 <span class="ep-desc">Get Confluence wiki markup for a run</span>
19798 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19799 </div>
19800 <div class="ep-body">
19801 <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>
19802 <p class="params-heading">Query Parameters</p>
19803 <table class="params">
19804 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19805 <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>
19806 </table>
19807 <p class="curl-heading">Example</p>
19808 <div class="curl-wrap">
19809 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19810 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
19811 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19812 </div>
19813 </div>
19814 </div>
19815 </div>
19816
19817 <!-- Authentication -->
19818 <div class="section">
19819 <h2 class="section-title">Authentication</h2>
19820 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19821
19822 <div class="ep-card">
19823 <div class="ep-header">
19824 <span class="method get">GET</span>
19825 <span class="ep-path">/auth/login</span>
19826 <span class="auth-badge public">Public</span>
19827 <span class="ep-desc">Login page</span>
19828 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19829 </div>
19830 <div class="ep-body">
19831 <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>
19832 <p class="params-heading">Query Parameters</p>
19833 <table class="params">
19834 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19835 <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>
19836 <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>
19837 </table>
19838 </div>
19839 </div>
19840
19841 <div class="ep-card">
19842 <div class="ep-header">
19843 <span class="method post">POST</span>
19844 <span class="ep-path">/auth/login</span>
19845 <span class="auth-badge public">Public</span>
19846 <span class="ep-desc">Submit credentials and get a session cookie</span>
19847 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19848 </div>
19849 <div class="ep-body">
19850 <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>
19851 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19852 <table class="params">
19853 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19854 <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>
19855 <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>
19856 </table>
19857 <p class="curl-heading">Example</p>
19858 <div class="curl-wrap">
19859 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19860 -d "key=$SLOC_API_KEY&next=/" \
19861 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19862 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19863 </div>
19864 </div>
19865 </div>
19866 </div>
19867
19868 <!-- Coverage Suggestion -->
19869 <div class="section">
19870 <h2 class="section-title">Coverage Suggestion</h2>
19871
19872 <div class="ep-card">
19873 <div class="ep-header">
19874 <span class="method get">GET</span>
19875 <span class="ep-path">/api/suggest-coverage</span>
19876 <span class="auth-badge protected">Protected</span>
19877 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19878 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19879 </div>
19880 <div class="ep-body">
19881 <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>
19882 <p class="params-heading">Query Parameters</p>
19883 <table class="params">
19884 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19885 <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>
19886 </table>
19887 <details class="schema"><summary>Response schema</summary>
19888<div class="schema-block">{
19889 "found": string | null, // absolute path to the coverage file, if detected
19890 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19891 "hint": string | null // shell command to generate coverage if not found
19892}</div></details>
19893 <p class="curl-heading">Example</p>
19894 <div class="curl-wrap">
19895 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19896 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19897 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19898 </div>
19899 </div>
19900 </div>
19901 </div>
19902
19903 </div>
19904
19905 <footer class="site-footer">
19906 oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
19907 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19908 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19909 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19910 · <a href="/api-docs" rel="noopener">REST API</a>
19911 </footer>
19912
19913 <script nonce="{{ csp_nonce }}">
19914 (function () {
19915 var base = window.location.origin;
19916 document.getElementById('base-url').textContent = base;
19917 document.querySelectorAll('.base-url-slot').forEach(function (el) {
19918 el.textContent = base;
19919 });
19920
19921 document.querySelectorAll('.ep-header').forEach(function (hdr) {
19922 hdr.addEventListener('click', function () {
19923 hdr.closest('.ep-card').classList.toggle('open');
19924 });
19925 });
19926
19927 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19928 btn.addEventListener('click', function () {
19929 var targetId = btn.dataset.target;
19930 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19931 if (!pre) return;
19932 navigator.clipboard.writeText(pre.textContent).then(function () {
19933 btn.textContent = 'Copied!';
19934 btn.classList.add('copied');
19935 setTimeout(function () {
19936 btn.textContent = 'Copy';
19937 btn.classList.remove('copied');
19938 }, 2000);
19939 });
19940 });
19941 });
19942
19943 var storageKey = 'oxide-sloc-theme';
19944 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19945 var themeBtn = document.getElementById('theme-toggle');
19946 if (themeBtn) {
19947 themeBtn.addEventListener('click', function () {
19948 var dark = document.body.classList.toggle('dark-theme');
19949 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19950 });
19951 }
19952 (function() {
19953 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'}];
19954 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);});}
19955 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19956 var btn=document.getElementById('settings-btn');if(!btn)return;
19957 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19958 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>';
19959 document.body.appendChild(m);
19960 var g=document.getElementById('scheme-grid');
19961 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);});
19962 var cl=document.getElementById('settings-close');
19963 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);
19964 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');});
19965 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19966 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19967 })();
19968 (function randomizeWatermarks() {
19969 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19970 if (!wms.length) return;
19971 var placed = [];
19972 function tooClose(top, left) {
19973 for (var i = 0; i < placed.length; i++) {
19974 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19975 if (dt < 16 && dl < 12) return true;
19976 }
19977 return false;
19978 }
19979 function pick(leftBand) {
19980 for (var attempt = 0; attempt < 50; attempt++) {
19981 var top = Math.random() * 88 + 2;
19982 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19983 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19984 }
19985 var top = Math.random() * 88 + 2;
19986 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19987 placed.push([top, left]); return [top, left];
19988 }
19989 var half = Math.floor(wms.length / 2);
19990 wms.forEach(function (img, i) {
19991 var pos = pick(i < half);
19992 var size = Math.floor(Math.random() * 100 + 120);
19993 var rot = (Math.random() * 360).toFixed(1);
19994 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19995 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;
19996 });
19997 })();
19998 (function spawnCodeParticles() {
19999 var container = document.getElementById('code-particles');
20000 if (!container) return;
20001 var snippets = [
20002 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20003 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20004 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20005 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20006 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20007 ];
20008 var count = 38;
20009 for (var i = 0; i < count; i++) {
20010 (function(idx) {
20011 var el = document.createElement('span');
20012 el.className = 'code-particle';
20013 el.textContent = snippets[idx % snippets.length];
20014 var left = Math.random() * 94 + 2;
20015 var top = Math.random() * 88 + 6;
20016 var dur = (Math.random() * 10 + 9).toFixed(1);
20017 var delay = (Math.random() * 18).toFixed(1);
20018 var rot = (Math.random() * 26 - 13).toFixed(1);
20019 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20020 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
20021 container.appendChild(el);
20022 })(i);
20023 }
20024 })();
20025 }());
20026 </script>
20027</body>
20028</html>
20029"##,
20030 ext = "html"
20031)]
20032struct ApiDocsTemplate {
20033 has_api_key: bool,
20034 csp_nonce: String,
20035 version: &'static str,
20036}